🛠️

ブラウザ拡張機能を作るためのReactフレームワーク『Plasmo』

2023/02/14に公開

Plasmoとは

Plasmoはブラウザ拡張機能を作成するためのReactフレームワークです。
テストや自動デプロイするための機能なども提供しておりオールインワンなフレームワークとなっています。
公式サイトに行くと「ブラウザ拡張機能におけるNext.js」という強気なワードが確認できます。

It's like Next.js for browser extensions!

Plasmoの特徴

Plasmoは以下のような特徴を持ったフレームワークです(公式より一部抜粋)。

  • ファーストクラスのReact+Typescriptサポート
  • ライブリローディング + React HMR
  • .env* ファイルサポート
  • BPP による自動デプロイメント
  • Svelte と Vue をオプションでサポート

またゼロコンフィグで開発を始めることができ、ブラウザ拡張機能の設定ファイルであるmanifest.jsonをほとんど意識することなく開発を進めることができます。
WebpackやViteなどのバンドラーの設定をする必要がなく、v2とv3の情報が混在しているmanifest.jsonの複雑性に悩まされることもないので、これだけでもある程度恩恵が感じられそうです。

本記事では簡単なChrome拡張を作成しながらPlasmoの機能を紹介してみたいと思います。
今回使用するソースコードは以下のリポジトリにあります。
https://github.com/nado1001/plasmo-example

セットアップ

プロジェクトの作成

早速プロジェクトの作成から初めてみましょう。
以下のコマンドで作成することができます。

pnpm create plasmo
# OR
yarn create plasmo
# OR
npm create plasmo

パッケージマネージャーはどれでも良いですが公式ではpnpmを推奨しているので本記事ではpnpmを使用していきます。
また、Node.jsのバージョンは16.14.x以上を推奨しています。

拡張機能の名前や説明などが質問されるので適当に答えていくと、以下のようなディレクトリ構成のプロジェクトが作成されます。

├── .github
│   └── workflows
│       └── submit.yml
├── README.md
├── assets
│   └── icon.png
├── node_modules
├── .gitignore
├── .prettierrc.cjs
├── package.json
├── pnpm-lock.yaml
├── popup.tsx
└── tsconfig.json

拡張機能をデベロッパーモードで読み込む

プロジェクトが作成できたら該当のディレクトリに移動して以下のコマンドを実行してみましょう。

pnpm dev

数秒待つとホットリロード機能を備えたdevelopment serverが起動しbuildディレクトリとその中にchrome-mv3-devというディレクトリが生成されます。
このchrome-mv3-devが開発用にバンドルされたChromeの拡張機能になります。
本番用の拡張機能はpnpm buildを実行することで生成されます。

次にchrome-mv3-devをブラウザで読み込み実際に拡張機能として使用できる状態にしてみましょう。
まずChrome拡張の設定ページ(chrome://extensions/)へ移動します。
この時ページ右上にあるデベロッパーモードがOFFになっている場合はONにしておいてください。

デベロッパーモード

ページ左上にある「パッケージ化されていない拡張機能を読み込む」というボタンを押下するとフォルダを選択する画面になるので先程生成したchrome-mv3-devを選択します。

パッケージ化されていない拡張機能を読み込む

選択し読み込みが完了するとブラウザ右上のツールバーにある拡張機能の一覧の中にDEV | (作成した時の名前)という拡張機能があるはずです。
クリックすると以下のようなシンプルなポップアップが表示されます。
初期状態の拡張機能拡張機能

試しにこのポップアップを少し編集してみたいと思います。
ポップアップのソースコードが書いてあるファイルはpopup.tsxになります。

以下のようになっているので適当に文字やCSSを編集し保存してみます。
保存するとホットリロードが機能し、再度拡張機能を表示することで編集した箇所が反映されていることが確認できるはずです。

import { useState } from "react"

function IndexPopup() {
  const [data, setData] = useState("")

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        padding: 16
      }}>
      <h2>
        Welcome to your{" "}
        <a href="https://www.plasmo.com" target="_blank">
          Plasmo
        </a>{" "}
        Extension!
      </h2>
      <input onChange={(e) => setData(e.target.value)} value={data} />
      <a href="https://docs.plasmo.com" target="_blank">
        View Docs
      </a>
    </div>
  )
}

export default IndexPopup

以上でセットアップは完了です。
以降でPlasmoの持つ機能について触れていきたいと思います。

様々なページの作成

Chrome拡張には先程のポップアップをはじめ様々なページがあります。
順を追って一つずつ見ていきたいと思います。

Chrome拡張といえばポップアップをイメージする方も多いのではないでしょうか。
セットアップの章でも少し触れましたがPlasmoでポップアップを追加するにはプロジェクトのルートにpopup.tsxを作成しReactコンポーネントをdefault exportします。
もしくはpopupというディレクトリを作成しその配下にindex.tsxを作成する方法もあります。

popup/index.tsx
import { useState } from "react"

function IndexPopup() {
  const [data, setData] = useState("")

  return (
    ...
  )
}

export default IndexPopup

ポップアップでのみ使用するコンポーネントなどを入れておくこともできるので個人的にはこちらの方が好みです。
https://docs.plasmo.com/framework/ext-pages#adding-a-popup-page

画像を扱う

ポップアップの作成を進めていると画像を使用したくなる場面もあるかと思います。
PlasmoにはNext.jsのpublicのようなディレクトリはなく、画像を個別にインポートして使用します。
またインポートする際、パスの先頭にdata-base64という接頭辞を付ける必要があります。

import someCoolImage from "data-base64:~assets/some-cool-image.png"
 
...
 
<img src={someCoolImage} alt="Some pretty cool image" />

もしくはChrome拡張の設定ファイルであるmanifest.jsonweb_accessible_resourcesに使用する画像を宣言し、chrome.getURLというChrome拡張のAPIで取得する方法もあります。
Plasmoではpackage.jsonmanifestに設定を記述することで生成される拡張機能のmanifest.jsonをオーバーライドすることが可能です。

package.json
{
  "manifest": {
    "web_accessible_resources": [
      {
         "resources": [
          "assets/pic*.png",
        ],
        "matches": [
          "https://*/*"
        ]
      }
    ]
  }
}
popup.tsx
const srcList = Array.from({
  length: 3
}).map((_, i) => chrome.runtime.getURL(`assets/pic${i}.png`))

function IndexPopup() {
  return (
    <div>
      {srcList.map((src, i) => (
        <img key={i} src={src} style={{ width: 44, height: 44 }} />
      ))}
    </div>
  )
}

export default IndexPopup

https://docs.plasmo.com/framework/assets

Options Page

Options Pageは拡張機能における設定画面です。
拡張機能を使用する上で必要な情報をOptions Pageで入力しChromeのStorage APIを利用してストレージに保存するといった操作がよく行われるのではないかと思います。

Plasmoではoptions.tsxを作成するかoptionsディレクトリの中にindex.tsxを作成することでOptions Pageが追加されます。
実際に先程作成したプロジェクトにOptions Pageを追加してみましょう。

options/index.tsx
import { useState } from "react"

function OptionsIndex() {
  const [data, setData] = useState("")

  return (
    <div>
      <h1>This is the Option UI page!</h1>
      <input onChange={(e) => setData(e.target.value)} value={data} />
    </div>
  )
}

export default OptionsIndex

Options Pageは拡張機能の詳細ページからアクセスすることができます。
「拡張機能のオプション」をクリックすると別タブでページが開き、記述した内容が確認できるはずです。

Options Page

Storageを利用する

Chrome拡張では拡張機能で使用するデータや状態を保持しておくためのStorage APIが用意されています。
localStorageやsessionStorageと似たようなものですが、ブラウザのキャッシュや閲覧履歴を消去してもデータが残り続けるといった特徴があるようです。

Storage APIをそのまま利用することも可能ですが、PlasmoではよりシンプルにStorageを扱えるようuseStorageというhooksを提供しています。
https://docs.plasmo.com/quickstarts/with-chrome-storage#storage-hooks

先程作成したOptions Pageでこちらのhooksを利用しデータをストレージに保存できるようにしてみたいと思います。
まずはhooksを提供しているライブラリをインストールします。またフォームを扱うためreact-hook-formも追加しておきます。

pnpm add @plasmohq/storage react-hook-form

インストールが完了したらOptions Pageを以下のように書き換えてみましょう。

options/index.tsx
import { SubmitHandler, useForm } from "react-hook-form"
import { useStorage } from "@plasmohq/storage/hook"

function OptionsIndex() {
  const [name, setName] = useStorage("name")

  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<{ name: string }>()

  const onSubmit: SubmitHandler<{ name: string }> = (data) => {
    setName(data.name)
  }

  return (
    <div>
      <h1>This is the Option UI page!</h1>
      <form onSubmit={handleSubmit(onSubmit)}>
        <label>
          Name:
          <input
            {...register("name", {
              required: "This is required"
            })}
            defaultValue={name}
            style={{
              border: errors.name ? "1px solid red" : "1px solid black"
            }}
          />
        </label>
        {errors.name && <p style={{ color: "red" }}>{errors.name.message}</p>}

        <button
          type="submit"
          style={{
            marginTop: "20px",
            display: "block"
          }}>
          Save
        </button>
      </form>
    </div>
  )
}

export default OptionsIndex

useStorageの使い方はとてもシンプルで現在のストレージの状態とそれを更新するための関数をタプル型で受け取ります。ほとんどuseStateと同じような書き方です。
useStorageに渡す引数がuseStateとは若干異なっており、第一引数にはストレージに保存する際のKeyを指定し、第二引数で任意の初期値を指定します。
上記のコードの例だとname: [入力した値]のような感じでストレージに保存されます。

この状態でSaveボタンをクリックすると入力した値がストレージに保存されるはずです。
余談ですがStorage Area Explorerという拡張機能を利用すると現在のストレージの状況がわかりやすくなるのでおすすめです。

New Tab Page

New Tab Pageはブラウザで新しいタブを開いた時に最初に表示される画面のことです。
デフォルトだとGoogleの検索画面がありその下にいくつかのショートカットが表示されているかと思います。Chrome拡張でNew Tab Pageを作成することでこの画面をカスタマイズすることができます。
Daily.devなどは利用している方も多いのではないでしょうか。

これまでと同じですがNew Tab Pageを追加するにはnewtab.tsxnewtab/index.tsxを追加します。

newtab/index.tsx
import { useState } from "react"

function IndexNewtab() {
  const [data, setData] = useState("")

  return (
    <div>
      <h1>This is the New Tab Page!</h1>
      <input onChange={(e) => setData(e.target.value)} value={data} />
    </div>
  )
}

export default IndexNewtab

New Tab Pageが追加できたらブラウザで新しいタブを開いてみましょう。特に問題がなければnewtab/index.tsxに記述した内容が表示されるはずです。
元に戻したい時はChromeの設定画面の起動時のページ(chrome://settings/onStartup)から「無効にする」を選択します。

New Tab Page

Dev Tools Page

Dev Tools Pageはブラウザ組み込みの開発ツール(Dev Tools)を拡張するための機能です。
最近リリースされたSWRのChrome拡張などもdevtools APIを利用して開発されたものになるかと思います。。

Dev Tools Pageを追加するにはdevtools.tsxdevtools/index.tsxを追加します。
また、これまでのページとは異なりDev Tools PageではReactコンポーネントをexportするのに加えて通常どおりdevtools APIの利用が必要になります。

ここではDev Toolsに簡単なパネルを追加してみたいと思います。
まずはパネルに表示させるHTMLを用意します。作成したdevtoolsディレクトリの中に以下のようなpanel.htmlを追加しましょう。

devtools/panel.html
<!DOCTYPE html>
<html>
  <head>
    <title>My Panel</title>
    <meta charset="utf-8" />
  </head>

  <body>
    <div id="panel">
      <h2>My Panel</h2>
      <p>HELLO WORLD</p>
    </div>
  </body>
</html>

続いてdevtools/index.tsx(またはdevtools.tsx)を以下のように記述します。

devtools/index.tsx
import myPanelHTML from "url:~/devtools/panel.html"

chrome.devtools.panels.create("My Panel", "", myPanelHTML)

function IndexDevtools() {
  return <></>
}

export default IndexDevtools

ここでは追加したHTMLをimportし、devtools APIを利用してMy Panelというパネルを追加しています。
HTMLをimportする際はurlという接頭辞を付ける必要があるようです。
createメソッドは第一引数にパネル名、第二引数に表示させるアイコン、第三引数に表示させるHTMLファイル、第四引数に作成したパネルの中で実行させるコールバック関数を指定することができます。

https://developer.chrome.com/docs/extensions/reference/devtools_panels/#method-create

exportしたReactコンポーネントはその内容がDev Toolsに表示されることはないので空にしておいて問題ありません。ただ、現状何らかのReactコンポーネントをexportしておかないとエラーになるようでした。

ここまでできたら適当なWebページでDev Toolsを開き一番上にあるパネルの一覧(Elements, Console, Networkなどがあるところ)を確認してみましょう。
一覧の下の方にMy Panelというパネルが存在し、クリックするとpanel.htmlの内容が表示されているのが確認できるはずです。

パネル一覧

Dev Toolsでのみ利用できるAPIを使った実装も試してみたいと思います。
Networkパネルのrequestとresponseの内容を取得しMy Panelへ表示させてみましょう。
devtools/index.tsxを以下のように編集します。

devtools/index.tsx
import myPanelHTML from "url:~/devtools/panel.html"

chrome.devtools.panels.create("My Panel", null, myPanelHTML, (panel) => {
  panel.onShown.addListener((panelWindow) => {
    let reqElem = ``

    chrome.devtools.network.onRequestFinished.addListener((request) => {
      const { url, method, headers } = request.request
      const { status } = request.response

      reqElem += `
        <div class="request" style="margin-top: 20px;">
          <div class="request__url">${url}</div>
          <div class="request__method">${method}</div>
          <div class="request__status">${status}</div>
          <div class="request__headers">${JSON.stringify(headers)}</div>
        </div>
      `

      panelWindow.document.getElementById("panel").innerHTML = `
        <div class="requests">
          ${reqElem}
        </div>
      `
    })
  })
})

function IndexDevtools() {
  return <></>
}

export default IndexDevtools

この例ではchrome.devtools.panels.createのコールバック関数内でパネルが表示される度に実行されるpanel.onShownイベントのリスナーを追加しています。
その中でchrome.devtools.network APIを利用してNetworkパネルで発生したrequestとresponseを取得し、いくつかの項目をHTMLに埋め込んでいます。
この状態で再度My Panelを表示すると閲覧しているWebページで発生したrequestとresponseの内容が表示されているのが確認できると思います。

Content Scriptsを追加する

Content Scriptsは閲覧しているWebページのCSSやJavaScriptを拡張機能から操作するための機能です。
PlasmoでContent Scriptsを追加するにはcontent.tsを作成するか、contentsディレクトリを作成しその中に任意の名前のtsファイルを作成します。
試しに表示しているWebページにconsole.logを表示させるContent Scriptsを追加してみましょう。
ここではcontentsディレクトリの中にsample.tsというファイルを追加しています。

contents/sample.ts
export {}
console.log("This is content script")

適当なWebページででconsoleを開くとsample.tsに記述したconsole.logの内容が表示されています。

この状態だと表示した全てのページでContent Scriptsが実行されていますが、Content Scriptsを利用する場合、特定のページでのみ実行させたいといったケースがほとんどだと思います。
特定のページで実行させたい場合はconfigという変数の中に設定を記述しexportする必要があります。
https://docs.plasmo.com/framework/content-scripts#config

configで設定できる項目についてはChromeのDocumentationを参照してください。
https://developer.chrome.com/docs/extensions/mv3/content_scripts/#static-declarative

試しにZennのページでのみ実行されるContent Scriptsを作成してみたいと思います。
contentsディレクトリの中にzenn.tsを作成し、以下のように記述してみましょう。

contents/zenn.ts
import type { PlasmoCSConfig } from "plasmo"

export const config: PlasmoCSConfig = {
  matches: ["https://zenn.dev/*"]
}

window.addEventListener("load", () => {
  document.getElementById("tech-trend").style.backgroundColor = "#333333"
})

見れば分かるかと思いますが、上記はZennにアクセスしページが読み込まれた段階でTOPページのTechトレンドの背景を黒に変更するContent Scriptsになります。
実際にアクセスすると背景が黒になっているのが確認できるはずです。
頑張ればダークモードで表示するための拡張機能も作れそうですね(Zennさんダークモード対応お願いいたします🙏)

ZennのTOPページ

Content Scripts UIを追加する

Content Scripts UI(以降CSUIと呼称します)はPlasmoが提供しているもので、React(またはSvelte, Vue)コンポーネントをContent Scriptsと同じようにWebページへ表示させる機能です。
Shadow DOMを利用し通常のDOMとは独立したところにコンポーネントがマウントされるため、Webページに埋め込んであるスタイルが影響したり、逆にCSUIのスタイルがWebページに影響したりといった事態が防げる仕組みになっているようです。
https://docs.plasmo.com/framework/content-scripts-ui

CSUIを追加するにはcontent.tsxcontentsディレクトリ配下にtsxファイルを作成します。
試しにgithubのページ上にボタンを追加するCSUIを作成してみたいと思います。
contentsディレクトリにgithub.tsxを作成し以下のように記述してみましょう。

contents/github.tsx
import type { PlasmoCSConfig } from "plasmo"

export const config: PlasmoCSConfig = {
  matches: ["https://github.com/*"]
}

const CustomButton = () => {
  return <button>Custom button</button>
}

export default CustomButton

無事Reactで作成したボタンをgithubに表示させることができました。

githubにCSUIを追加

Background Service Workerを扱う

Background Service Worker(以降BSWと呼称します)は拡張機能のバックグラウンドにあるJavaScriptの実行環境です。名前の通りService Workerを利用しているので常にバックグラウンドで起動しているわけではなく特定のタイミングで読み込み実行され、それ以外はアイドル状態となりスクリプトはアンロードされます。

Service Workerについては以下の記事がわかりやすかったのでこちらに説明を委ねます。
https://qiita.com/poster-keisuke/items/00056b8d5d6275afdb1a

BSWが実行されるタイミングは以下の通りです。

  • 拡張機能が起動した時
  • 拡張機能が初めてインストールされた時、もしくは新しいバージョンに更新された時
  • 登録したイベントリスナーが発火した時(参考)
  • Content Scriptsや他のページからメッセージが送信された時

PlasmoでBSWを実行するにはbackground.tsを追加します。

https://docs.plasmo.com/framework/background-service-worker

実際にBSWが起動していることを確認するためにconsole.logを実行してみましょう。
background.tsに以下のコードを記述します。

background.ts
export {}
console.log("This is background service worker")

バックグラウンドで実行されたconsole.logを確認するには以下のように拡張機能の設定ページにある「service worker」の部分をクリックします。するとDev Toolsが開くのでConsoleのパネルへ移動するとbackground.tsconsole.logが実行されていることが確認できるはずです。

BSWの確認

Messaging APIを利用する

「BSWが実行されるタイミング」のところでも少し触れましたがContent ScriptsやポップアップなどのページからBSWにメッセージを送信し、それをトリガーにバックグラウンドでJavaScriptを実行することができます。

通常、chrome.runtime.sendMessage APIを利用してメッセージを送信しますが、Plasmoではより宣言的かつ型安全にメッセージの送受信が行えるようにMessaging APIを提供しています。

ここではMessaging APIを利用してポップアップからBSWにメッセージを送信し、受信したタイミングで外部のAPIからデータを取得してポップアップへ返却する処理を試してみたいと思います。
BSWからAPIへのリクエストを行うことでCORSを回避できるといったメリットもあります。

まずは以下のライブラリをインストールします。

pnpm add @plasmohq/messaging

続いてポップアップにBSWへメッセージを送信する処理を先に書いておきましょう。
ここでは先程インストールした@plasmohq/messagingsendToBackgroundを利用してボタンがクリックされたらpostsという名前のメッセージを送信し、BSWから返却された結果をstateに保存しています。

popup/index.tsx
import { useState } from "react"
import { sendToBackground } from "@plasmohq/messaging"

function IndexPopup() {
  const [posts, setPosts] = useState([])

  return (
    <div
      style={{
        width: "300px",
        height: "300px"
      }}>
      <button
        onClick={async () => {
          const res = await sendToBackground({
            name: "posts"
          })
          setPosts(res.posts)
        }}>
        Get posts
      </button>

      {posts.length > 0 && (
        <div>
          {posts.map((post) => (
            <div
              key={post.id}
              style={{
                marginTop: "20px"
              }}>
              <div>{post.title}</div>
              <div>{post.body}</div>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

export default IndexPopup

次にバックグラウンドで外部のAPIからデータを取得しポップアップへ返却する処理を書いていきます。
先程はルートにbackground.tsを作成しましたが、Messaging APIを利用するにはbackground/messagesディレクトリを作成しその配下にtsファイルを配置する必要があるようです。また、tsファイルのファイル名はメッセージを送信するときにnameに指定した名前にする必要があります(今回の例だとposts

Plasmoの規約に従いbackground/messages/posts.tsを作成します。
作成したtsファイルに以下のコードを記述してみましょう。

background/messages/posts.ts
import type { PlasmoMessaging } from "@plasmohq/messaging"

const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
  const posts = await fetch(`https://jsonplaceholder.typicode.com/${req.name}`)
    .then((res) => res.json())
    .then((json) => json.slice(0, 10))

  res.send({
    posts
  })
}

export default handler

ここではリクエストに指定された名前を元にjsonplaceholderから記事のデータを取得し、先頭10件を抽出してレスポンスとして返しています。
最後にこのhandler関数をエクスポートすることでBSWからこのモジュールを呼び出し、ポップアップにデータを返却することができるようになりました。

期待通りに動作するか確認してみましょう。
ポップアップを開きGet postsボタンを押下するとjsonplaceholderから取得したデータが表示されることが確認できるはずです。

BSWからメッセージを受信したポップアップ

これでポップアップとBSWとでメッセージを送受信する実装ができました。

Chrome Web Storeへ自動デプロイする

PlasmoではBrowser Platform PublisherというGitHub Actionを提供しています。
これによって簡単に拡張機能を(chrome拡張の場合は)Chrome Web Storeへ自動デプロイすることができます。
以降で自動デプロイに必要な作業の手順をまとめていきたいと思います。

Google APIにアクセスするためのアクセストークンの取得

Browser Platform Publisherは内部でChromeのWeb Store Publish APIを利用しています。
Web Store Publish APIを利用するためにはGoogle Cloudでプロジェクトを作成し、APIの有効化とアクセストークンの取得を行う必要があります。

まず初めにGoogle Cloudのプロジェクトを作成しましょう。
Google Cloud Consoleへ移動し「新しいプロジェクト」からプロジェクトを作成します。
この時のプロジェクト名は何でも良いです。

Google Cloudプロジェクト作成

プロジェクトの作成が完了したらそのプロジェクトを選択後、OAuth 同意画面へ移動します。
移動した先にUser Typeという選択項目があるのでExternalを選択し作成ボタンをクリックします。

OAuth 同意画面

作成ボタンを押すとアプリ情報などの入力画面に遷移します。
ここでは必須項目となっている、

  • アプリ名
  • ユーザーサポートメール
  • デベロッパーの連絡先情報

の3つのみ入力すればokです。
この後スコープという画面へ移りますがここでは特に何もせず「保存して次へ」をクリックします。

アプリ情報入力画面

次にテストユーザーという画面が表示されるので「ADD USERS」をクリックし、出てきた入力欄に自身のメールアドレスを追加しましょう。
テストユーザー

続いてChrome Web Store APIへ移動しAPIを有効化します。有効化できたら認証情報の画面へ遷移しましょう。
認証情報画面でCreate credentialsをクリックしその中にあるOAuth client IDを選択します。

認証情報画面

選択するとApplication typeとNameの入力欄が現れるのでApplication typeでは「Desktop app」を選択しNameには適当な名前を入力します。
Application type

作成ボタンをクリックすると数秒後にOAuthのクライアントIDとクライアントシークレットが発行されるのでJSONでダウンロードしておいてください。
OAuthクライアント

ダウンロードできたら再度OAuth 同意画面へ移動します。
公開ステータスがテストになっているので「アプリを公開」をクリックして公開しておきましょう。
これでGoogle Cloudでの作業は完了になります。以下のドキュメントにも同じ内容が記載されているのでもし分かりにくいところがあればこちらも参照してみてください。
https://developer.chrome.com/docs/webstore/using_webstore_api/

リフレッシュトークンの取得

Chrome Web Store APIを利用するために必要な情報のうちrefreshTokenがまだ足りていないのでこちらを取得していきます。
https://github.com/PlasmoHQ/bms/blob/main/tokens.md#chrome-web-store-api
Plasmoで作成したプロジェクトに戻り、先程ダウンロードしたJSONファイルをkey.jsonという名前でプロジェクトのルートへ配置します。
key.jsonの中身は以下のようになっているはずです。

{
  "installed": {
    "client_id": "xxxxxxx.apps.googleusercontent.com",
    "project_id": "Google Cloudで作成したプロジェクトのID",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://oauth2.googleapis.com/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_secret": "xxxxx-xxxxxxx-xxxxxxxxxxx",
    "redirect_uris": ["http://localhost"]
  }
}

配置できたら以下のコマンドを実行しましょう。
別のパッケージマネージャーを利用している時はnpxyarn dlxに置き換えてください。

pnpm dlx gcp-refresh-token

コマンドを実行すると、ブラウザでOAuthの同意画面が開きます。
警告も表示されますが手順に従い続行していくと最後に以下のメッセージが表示されます。
Code retrieved 🚀 on port: 1701. Please close this tab and return to the console.
メッセージに従いタブを閉じたらエディターに戻りkey.jsonを確認してみましょう。
installedの下に以下の情報が追加されているのが確認できると思います。

key.json
  "chrome": {
    "clientId": "xxxxxxx.apps.googleusercontent.com",
    "clientSecret": "xxxxx-xxxxxxx-xxxxxxxxxxx",
    "refreshToken": "xxxxxxxxxxxxxxxxxxxxxxxxxx"
  }

これでChrome Web Store APIの利用に必要なトークンを揃えることができました。

トークンをGithub Secretsに登録する

Browser Platform Publisherを実行するにあたり、これまで取得したトークンをJSON形式でGithub Secretsに登録しておく必要があります。
Chrome拡張を公開する際のJSONは以下の通りです。

{
  "$schema": "https://raw.githubusercontent.com/plasmo-corp/bpp/v2/keys.schema.json",
  "chrome": {
    "clientId": "xxxxxxx.apps.googleusercontent.com",
    "refreshToken": "xxxxxxxxxxxxxxxxxxxxxxxxxx",
    "extId": "xxxxxxxxxxxxxxxxxxxxxxxxx",
    "clientSecret": "xxxxx-xxxxxxx-xxxxxxxxxxx",
  }
}

clientId, refreshToken, clientSecretkey.jsonで生成されたものをそのまま入力してもらえば問題ありません。
extIdは拡張機能に割り当てられているIDになります。Chrome Web Storeのダッシュボードから対象の拡張機能の編集画面へ遷移するとIDが記載されているはずです。
もしくはそのページのURLでも確認できます。
https://chrome.google.com/webstore/devconsole/[ユーザーID]/[拡張機能のID]/edit

全ての項目が埋められたらこちらのJSONをSUBMIT_KEYSという名前でGithub Secretsに登録しましょう。名前をSUBMIT_KEYSにしているのは.github/workflows/submit.ymlで指定されているためです。
Github Secretsの登録の仕方は以下を参照してください。
https://docs.github.com/ja/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository

Browser Platform Publisherの実行

Secretsの登録ができたらBrowser Platform Publisherを実行してChrome Web Storeへデプロイしてみましょう。
該当のリポジトリのActionsタブへ移動しSubmit to Web Storeというworkflowを選択します。

Submit to Web Store

右側にRun workflowというボタンが表示されるのでこちらをクリックしてworkflowを実行します。特にエラーが発生しなければ1分前後で実行が完了します。

Browser Platform Publishでエラーが発生する場合

私の環境ではworkflowの中のBrowser Platform Publishを実行中に以下のエラーが発生しました。

ERROR | Error: chrome: Extension bundle file doesn't exist: /home/runner/work/hoge/hoge/build/chrome-mv3-prod.zip

エラーの内容は書いてある通り、Web Storeの審査に提出するためのzipファイルが存在しないというものです。
このworkflowの中でzipファイルの生成はBuild and zip extension artifactの部分で行われています。

.github/workflows/submit.yml
 - name: Build and zip extension artifact
    run: pnpm package

原因を調査したところpnpm packageのみではzipファイルが生成されず直前にpnpm buildを実行することでzipファイルが生成されるようになりました。

.github/workflows/submit.yml
 - name: Build and zip extension artifact
-    run: pnpm package
+    run: |
+      pnpm build
+      pnpm package

原因はシンプルですが、こんなバグが放置されているとも考えにくかったので一旦このような形で記録を残しています。
Plasmoにissueを立てたので進展があればこちらに追記したいと思います。
https://github.com/PlasmoHQ/plasmo/issues/441

2/16 追記
私が使用しているPlasmoのバージョンが少し古かったことが原因でした。
最新のバージョンでは解消されているようです。

Run workflow

無事拡張機能をWeb Storeにデプロイすることができました🎉

Browser Platform Publisher実行完了

Chrome Web Storeのダッシュボードでも拡張機能のステータスが「審査待ち」になっていることが確認できるはずです。

まとめ

長くなってしまいましたがここまで読んでいただきありがとうございました。
ブラウザ拡張機能開発というニッチな分野であるが故にあまり日の目を見ることのないPlasmoですが、扱いやすいAPIや自動デプロイの仕組みがあり、manifest.jsonをほとんど意識せず開発できるDXの良さも備えているなど、ブラウザ拡張機能におけるNext.jsを謳うのも納得の完成度のように感じました。
今後もブラウザの拡張機能を作る機会があればPlasmoは候補に上がりそうです。

本記事はPlasmoの機能の全てを紹介できてはいないので興味のある方は是非公式のドキュメントも読んでみてください。
また、間違っている情報やtypoなどあればコメントやプルリクなどいただけるととても助かります。

Discussion