Open60

WXTの調査とメモ

ひげひげ

挙動

pnpm run dev

ターミナルでこのコマンドを入力すると次のことが起こる。

  • 拡張機能がインストールされた状態でChromeが起動
  • 初期ページはabout:blank
  • browser.runtime.onInstalledイベントが発生している。

注意点

  • Chromeをインストールしていないとエラーが発生する
  • chrome://extensionsを開くと拡張機能一覧が表示される。デベロッパーモードはオフになっているので、デバッグをするときは手動でオンにする必要がある。
ひげひげ

初期ページを変更する

初期ページを変更するにはwxt.config.ts内にstartUrlsにURLを設定する。開発をする際にブランクページでは不便なので、対象としているサイトやGoogleのサイトなどを入力するといい。

  export default defineConfig({
  extensionApi: 'chrome',
  modules: ['@wxt-dev/module-react'],
  runner: {
    // 開発時に起動するURL
    startUrls: ["https://google.com/"]
  },
  manifest: {
    version: '1.0.0',
    permissions: ['storage', 'tabs'],
    action: {},
  }
});

インストール時の処理を書く

インストール時に設定したいことがあればバックグラウンドスクリプトに記述する。例えばストレージの初期値を設定するといい。

import {storage} from "@wxt-dev/storage";
import {browser} from "wxt/browser";

export default defineBackground(() => {
  console.log('Hello background!', { id: browser.runtime.id });
  browser.runtime.onInstalled.addListener(() => {
    // onInstalledイベントは拡張機能を更新したときにも発生するので、インストール時だけに発生させたい場合は以下のように分岐させる
    if(details.reason == "install") {
      storage.defineItem("local:animal", {
        init: () => "dog"
      })
    }
  });
});

ストレージに保存できたか確かめるために、ポップアップを開き、ポップアップ画面の上で右クリックをて検証を開き、Consoleタブで以下のコマンドを入力する。"dog"が記録されていると成功。初期化するにはinit()メソッドを使う。他のページでfallbackに初期値を入れている場合があるが、あれはgetValueの返り値のときにストレージがnullのときに返す値。しかもgetValue()defineItemの返り値の型だから、データ作成とデータ取得(or 保存?)を同時にやっている実装のときに使える。今やってるのはデータ作成だけで、getValue()は使っていないから初期値はinit()で設定する。

chrome.storage.local.get(null, (data) => {
  console.log(data);
});
ひげひげ

Storageのnullチェックが冗長

getItemでPromiseが帰ってくるためnullチェックをしなければならない。defineItem()を呼び出すときに、fallbackinit()を設定してもnullチェックは必要。そこは仕方がない。
storage.tsから呼び出して、fallbackを設定してたらnullchekは不要
initだけならnullcheck必要

ひげひげ

Storageの内容をポップアップに表示するときはuseEffectを使う

Reactは`Promise`の値を直接表示することはできないので、`useEffect`と`useState`を組み合わせて使う。`useEffect`のコールバック関数に`Promise`を返す関数を指定することはできない。その対処法として、`useEffect`の内か外にStorageを操作する非同期関数を定義し、`useEffect`の内側でその関数を呼び出す。
    const [duration, setDuration] = useState<string>("");

    useEffect(() => {
        async function fetchDuration() {
            const now = new Date();
            const working_status = await workingStatus();
            const result = await getDurationTime(now, working_status);
            setDuration(result[0]);
        }
        fetchDuration();
    }, []);

    return (
      <>
      <a>{duration}</a>
      </>
  )

https://zenn.dev/k_kazukiiiiii/articles/684c4d2d5390c3

ひげひげ

useStateの初期値をstorageから取り出した値にする

storageの値を画面に表示するのはややこしい。愚直にやろうとするとuseStateuseEffectstorageを組み合わせた処理を作らなくちゃならない。WXTはReactとStorageを連携する便利なHooksがないからきつい。PlasmoならReact Hook APIというのが提供されているから次のようにReactの文法に沿った方法で書ける。

const [hailingFrequency] = useStorage("hailing")
...
{hailingFrequency}

これを見てPlasmoに乗り換えたくなったが、Plasmoはドキュメントがしっかりしてないから調べるのが大変そう。さすがに大変だから調査が必要
https://docs.plasmo.com/framework/storage#react-hook-api

ひげひげ

entrypointsというディレクトリ名は変えない方がよい

JSで開発をしているとsrcディレクトリにファイルを入れたくなる。しかしWXTではsrcディレクトリは存在しない。代わりにentrypoints(起点)という名前のディレクトリが中心的な役割を果たす。entrypointsにはコンテンツスクリプト、バックグラウンドスクリプト、UIなど拡張機能の主要なファイルを入れる。一方、utilsや型ファイルといった補助的なファイルをルートディレクトリに配置することで役割を分けている。拡張機能の主要なファイルの起点をentrypointsディレクトリに配置している。
また、entrypointsディレクトリに入れてあるファイル名は命名規則に従うと、ビルド時には自動的にファイル名が変更され適切はディレクトリ構成に変換される。WXTの考え方に沿ってディレクトリ名は変えない方がいいだろう。一応名前を変えることはできる。

ひげひげ

Message API はWXTで提供されていない

拡張機能のフレームワークでstorageとMeesage API は必須だろうと思っていたが、WXTの作者はMessage API をおそらく作らないだろうと思われる。製作者本人がRedditやDiscussionでMeesage APIの考え方はあまり好きでないと書いていた。
https://www.reddit.com/r/chrome_extensions/comments/1fs9om2/comment/lpj422n/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

https://github.com/wxt-dev/wxt/issues/643

作者が言うには上図のようにデータ共有するときは3つの考え方がある。Message APIは3番目の感が型。作者は1番目のBackground as an "API" という、バックグラウンドをAPIとして活用する方法が好ましいと考えている。関係あるか分からないが、entrypints のドキュメントではBackgroundScriptが一番上に記載されてあるし、BackgroundScriptだけ1つしかファイルを設定できない。

また、Background as an "API" を実現するためにtrpc-chromeというライブラリを使うといいと勧めている。一方、Messaging API のラッパーのライブラリを使えるようになっている。
https://github.com/jlalmes/trpc-chrome

ひげひげ

WXT で作られた拡張機能

一覧

https://github.com/search?q=wxt/storage&type=code

Youtubeの画像スクショ

日本人が作っている。コメントが多いので参考になる
https://github.com/kerobot/youtube-snapshot-wxt/tree/main

YoutubeのサムネをEagleに保存する

https://github.com/eetann/thumbnail-to-eagle/tree/0bdb14c13b7966cc2ca633db5282260d9dd36f65

クッキー編集

https://github.com/SzymonNastaly/CookieAudit/tree/c484228e89c5741fd3b402529aded5c0f52ea7d2

OneTabみたいなもの

結構大きめのコードなので参考になる
https://github.com/web-dahuyou/NiceTab

手の込んだような小さいような拡張機能

https://github.com/98kb/siddu/blob/68edef89fdc6e68d7185083f1bf1c45aa856de39/packages/chrome-extension/lib/trpc/index.ts

figmaのなんかの拡張機能

declarativeNetを使っている
https://github.com/zouhangwithsweet/fubukicss-tool/tree/2c0edc7595b88ae707173b17d643d423a06f9c64II

ひげひげ

コンポーネントは自動でインポートされる

WXTではReactのコンポーネントを明示的にインポートせずとも自動インポートする機能がある。そのためにはルートディレクトリにcomponentsディレクトリを作成しておき、コンポーネントのファイルを配置すると可能になる。わざわざimport '@/compoenent/Main.tsx'などと書かなくていい。

新しく追加したコンポーネントはすぐに認識しないので、一度Control + CでWXTを停止させて再び起動する。

画像のように読み込みエラーが出るときがある

画像のようにコンポーネントに読み込みエラーがでると、WXTを再起動しよう

ひげひげ

storagedefineItemの返り値の型はWxtStorageItemである

https://wxt.dev/storage.html](https://wxt.dev/storage.html#defining-storage-items
storageのサンプルコードを見ると、utils/storage.ts内にこんなふうにdefineItemで生成した値を使っている。

// utils/storage.ts
const showChangelogOnUpdate = storage.defineItem<boolean>(
  'local:showChangelogOnUpdate',
  {
    fallback: true,
  },
);
この`showChangelogOnUpdate`を使って`setValue`や`getValue`を行ってデータの読み書きを行うことになる。しかしこれだと、呼び出すたびに`defineItem`メソッドでアイテムを定義していることに、何度もデータを作成していることに違和感を感じる。またデータを定義しているのなら、変数名も`getXxxx`のように接頭辞に`get`を使ってデータを取得したことを示すのが分かりやすいのではないかと疑問に思った。
`defineItem`メソッドの型は`WxtStorageItem`というもので、この型には`getValue`、`setValue`、`watch`などおおよそ使いたいメソッドが提供されている。なので、`defineItem`を呼び出している時点でデータに対しての操作(CRUD)は行っておらず、むしろCRUD操作を行うためのインスタンスのような役割を担っている。なので、`showChangelogOnUpdate`という変数名は正しい。

Itemのニュアンスは、JSONの特定のプロパティを抽象化したものというイメージをするといい。プロパティとはキーとバリューの組み合わせのこと。ストレージはRDBではなく、1つの大きなObject型の値を永続化したものと捉えるのが良さそうだ。

ひげひげ

/componentsに配置したコンポーネントはApp.tsxで自動的にインポートされている

TSXの内容も特別なことは書かなくてもいい。普通にexport defaultを書くだけでいい

import React from 'react';
import styles from './Information.module.css';

export default function Information() {

    return (
        <>
            <div className={styles.informationContainer}>
                <p className={styles.status}>就労中</p>
                <button className={styles.statusButton} >開始する</button>
            </div>
        </>
    )
}
ひげひげ

storagewatchをReactのどこに記述すべきか

失敗例。関数コンポーネントのトップレベルに書いてしまった

こんなふうに、storage.watchuseEffectを使わずに書いていたがReactの書き方としては良くないらしい。ChatGPTがそう言ってた。

export default function App() {
  /* state宣言とかの記述 */
  storage.watch<boolean>('local:isFlag'), (new, old) { /* 変化の検出時に行う処理の記述 */ };

  return <></>
}

これのミスはuseEffectを使わなかったことだ。useEffectを使うことでメリットは3つある

  1. コンポーネントがマウントされるたびにstorage.watchが再登録されてしまい、予期せぬ挙動を引き起こす可能性がある
  2. クリーンアップ関数が書ける
  3. ReactでStateを監視・更新する際にはuseEffectを行うのが一般的

1について、確かにwatchの中に検出した値をalertで表示するような記述をしてたら複数回表示されるという原因不明のエラーに陥った。useEffectの中にwatchを書くと治った!
2について、watchの返り値は監視をやめるオブジェクトが返ってくる。これをそのままunwatchという変数名に入れて、クリーンアップ関数としてそのまま使うことができる。製作者はuseEffectを使うことを考慮に入れてくれていたのかもしれない。
3について、これは知らなかった。useEffectについてはかなり議論されてそうだから、使うタイミングについて調べる必要がある
https://zenn.dev/uhyo/articles/useeffect-taught-by-extremist

https://www.lyricrime.com/posts/react-use-effect/

storage.watchuseEffectに書く

useEffectasync関数は書けないので関数を新たに定義する

useEffectで実行したい関数が非同期処理を行う場合、なんとなくこう書きたい。

// 間違った例
useEffect(async () => {
    const response = await fetch("https://www.googleapis.com/books/v1/volumes?q=AWS");
    const data = await response.json();
    console.log(data);
  },[]);

しかしこの書き方はエラーになる。正しくは次のような書き方をする。

  useEffect(() => {
    const fetchBooksData = async () => {
      const response = await fetch("https://www.googleapis.com/books/v1/volumes?q=AWS");
      const data = await response.json();
      alert(data.totalItems);
    };

    fetchBooksData();
  }, []);

一度非同期関数を作成して、その後すぐに実行している。
なぜ初めの書き方でエラーになるかというと、useEffectの第一引数の関数の戻り値にはクリーンアップ関数(returnで返してるいつものアレ)を設定する必要がある。asyncを使って非同期関数を書くと、実装上でreturnを書かなくても、暗黙的にPromise型が戻り値として設定される。よって、間違った例では第一引数の返り値がPromiseになってしまいエラー。

また、useEffect内で即時関数を使って書く方法もあるが、やらない方がいい。

// よくない例
useEffect(() => {
    (async() => {
      const response = await fetch("https://www.googleapis.com/books/v1/volumes?q=AWS");
      const data = await response.json();
      alert(data.totalItems);
    })()
  }, []);

https://zenn.dev/syu/articles/b97fb155137d1f

watchの有効範囲は謎だが、少なくともポップアップが開いている状態だと、別のウィンドウの別のタブを開いてもstorageの変更を検出できていた。watchをバックグラウンドスクリプトやコンテンツスクリプトで呼び出すと、もう少し長生きできるのかもしれない。

ひげひげ

初回レンダーだけ初期値を表示させてそれ以降は書き換え可能

storageに表示された値をデフォルト値としてフォームの入力欄に表示させたい。そこで次のようにinputvalueにstateの変数を代入していた。こう書くと、フォームに何を入力してもstateに保存された値に書き戻されてしまう。

const [todo, setTodo] = useState<string>("");

<input type="text" id="todo" placeholder="業務内容" value={todo} />

useEffectではDOMの値にアクセスできないので、useEffect内でinput.value = "初期値"のように変更はできない。そもそもDOMの値を直接変更することは非推奨!!useStateuseRefなどを使ってstateを使って書き換える必要がある。

onChangeハンドラーを使う

    const handleChange =   (e: React.ChangeEvent<HTMLInputElement>) => {
        setTodo(e.target.value)
    }

    reuturn(
        <>
            <input
                type="text"
                value={todo}
                onChange={handleChange}
            />
        </>
    )
ひげひげ

ページ遷移のときに発生するイベントをコンテンツスクリプトに書いたときのエラー処理

送信ボタンを押したとき、querySelectorを使ってフォームの内容を取得するコードを書いていた。そこでは自分のミスで構文エラーが発生するコードを書いてしまった。

// 間違い。 nameのバリューを" "で囲む必要がある
    const todo = document.querySelector(`input[name=${TODO_PARAM}]`)  as HTMLInputElement;


// 正しい
    const todo = document.querySelector(`input[name="${TODO_PARAM}"]`)  as HTMLInputElement;

文字列が評価されるのはコードの実行時なので、querySelectorでエラーが発生するのだが、このコードはページ遷移の直前に実行されるものだった。下のコードのように書いても、構文エラーだからif文は拾ってくれない。もしかしたら、構文エラーを検出しているのかもしれないが、ログが読める前にコンソールがリフレッシュされて何もエラーが表示されなかった。さすがに困る。try-catchをしようとchatGPTに言われたが、ページ切り替わりのときはtry-catchを囲んだ方がいいのかなあ。

function getTodoField() {
    const todo = document.querySelector(`input[name="${TODO_PARAM}"]`)  as HTMLInputElement;

    if (todo == null) {
        alert("error")
        console.error("todo が見つかりませんでした")
        return undefined;
    }

    return todo.value;
}
ひげひげ

初期値としてstorageから取得した値を表示する

方法1 storageのプロミスチェーン内でsetState

storage系のメソッドはPromiseを返すから、storage.getItem()で値を取得してから、then()の中でsetStateを使ってUIの更新を行う。

    storage.getItem('local:showFps').then((v) => {
      setShowFps(v);
    });

方法2 setState内でstorage.

// utils/sotorage.ts
export const snapshotShowFps =
  storage.defineItem<boolean>('local:snapshot-show-fps', {
    fallback: true,
  });


// App.ts
import { localShowFps, localPosition, localMiniMode } from '@/utils/storage';

  useEffect(() => {
    const getStorageValues = async () => {
      setMiniMode(await localMiniMode.getValue());
      setShowFps(await localShowFps.getValue());
      setPosition(await localPosition.getValue());
    };
    getStorageValues();
  }, []);

感想

方法2がいいかな。再利用できるし。

どちらの方法も、初めにstorageの値が変更され、その後にUIが更新されることになる。storageに保存された内容を画面に出力するのだからこの順番は正しい。
変数名の衝突を避けるため、上のどちらかの方法でやった方が綺麗にかける。似たような名前が

  1. storageのキー名
  2. utils/storageにでdefineItemしている変数名
  3. useStateの変数名

2と3で変数名が衝突する。storageに保存している値、useStateで設定した状態の値は同じ値を持っておきたい体。前者は永続化のための保存で、後者はUIの保存だ。だから名前が衝突するのは仕方がない。方法1ではキー名を使って新しくstorageから読み込むことで、storageの変数名の使用を避けている。方法2では、storageからの読み込みを別ファイルで定義して再利用可能な形にしている。このデメリットは名前が衝突するから変数名がややこしくなっている。prefixとしてlocalという文字をつけている。

import { todo as localTodo } from "@/utils/storage"

    const [todo, setTodo] = useState<string>("");

    useEffect(() => {
        // useEffectでasyncを使うため匿名関数を使っている。 初期値をstorageの値に設定
        (async () => {
            const storedValue = await localTodo.getValue();
            if (storedValue != null) {
                setTodo(storedValue);
            }
        })();
    // 省略
    }, []);

ひげひげ

createIntegratedUIReact.createRootを使ってルートコンポーネントを新しく作っているコードがあった。この方法でポップアップ以外でもReactの新しいルートコンポーネントを作れる。Reactタブを開こう。

https://wxt.dev/guide/essentials/content-scripts.html#integrated

この人のようにコンポーネントを読み込んで使うこともできる。
https://github.com/dcontt00/buleria-helper/blob/28f98aaea9a50a8fcf1a36a7b292546d368e28ae/entrypoints/file-rename.content/index.tsx#L17-L36

ひげひげ

createIntegratedUiは思ったよりも薄い関数だった。しかしTypeScriptの型の文法がよくわからんのでこれもよくわからん。しかし完全にわからないというわけではない。
https://github.com/wxt-dev/wxt/blob/5b557d02246005f7a0aca6ad5991794a3e2db3cb/packages/wxt/src/client/content-scripts/ui/index.ts#L32-L69

position

ソースコード

positionの入力値はinlineoverlaymodalの3つの値がある。これら3つはそれぞれ型として定義されている。inlineContentScriptInlinePositioningOptionsoverlayに対応するのはContentScriptOverlayPositioningOptionsmodalに対応するのはContentScriptModalPositioningOptions
https://github.com/wxt-dev/wxt/blob/5b557d02246005f7a0aca6ad5991794a3e2db3cb/packages/wxt/src/client/content-scripts/ui/types.ts#L180-L183

それぞれの型はCSSセレクタっぽい見た目。
https://github.com/wxt-dev/wxt/blob/5b557d02246005f7a0aca6ad5991794a3e2db3cb/packages/wxt/src/client/content-scripts/ui/types.ts#L144-L173

anchor

引数のanchorはCSSセレクタ、XPath、要素、もしくはこの3つのいずれかを戻り値とする関数を入れる。戻り値はいずれの場合もElementになる。CSSセレクタとXPathは文字列として渡し、要素はquerySelector()などで取得したElement型で渡す。ソースコードはgetAnchor()で定義されている。

ソースコード

渡されたのがCSSセレクタ、XPath、要素、関数であっても対応できるようにうまく書いている。
https://github.com/wxt-dev/wxt/blob/5b557d02246005f7a0aca6ad5991794a3e2db3cb/packages/wxt/src/client/content-scripts/ui/index.ts#L230-L255

append

anchor要素の位置に対して、UIをどこに配置するかを指示するプロパティ。画像のように配置される。

ソースコード

anchorは上で書いたようにElement型の値。anchor要素に対してappend()prepend()replaceWith()などのメソッドを使って要素を挿入して、anchorを基準にして位置を指定している。
https://github.com/wxt-dev/wxt/blob/5b557d02246005f7a0aca6ad5991794a3e2db3cb/packages/wxt/src/client/content-scripts/ui/index.ts#L257-L288

ひげひげ

ログイン情報を保存して次回の起動時に利用する

WXTはMozillaが提供するwxt-webを利用しているので、devコマンドで実行すると、履歴やクッキーが保存されていない状態でブラウザが起動します。データを残さない状態を保てるのは便利ですが、ログインが必要なサイトの拡張機能を作るときには不都合。そこでWXTの開発時に永続データを読み込む方法を紹介する。

wxt.config.tsrunnerというオプションを追加した次のコードを追記する。

import { defineConfig } from 'wxt';

  runner: {
    chromiumArgs: ['--user-data-dir=./.wxt/chrome-data'],
  }
});

次にブラウザを開いて目的のサイトでログインをすると、.wxt/chrome-dataに多くの設定ファイルが作成されていることがわかります。次回以降はWXTで開いたブラウザはこれらのファイルを読み書きするので再度ログインする必要がなくなります。.wxtディレクトリはデフォルトで.gitignoreに指定されているので、ログインデータなどが他の人に共有されることはありません。また、クッキーは.wxt/chrome-data/Default/Cookiesのファイルに保存されています。SQLiteビュワーを使って確認してみてください。

https://wxt.dev/guide/essentials/config/browser-startup.html#config-files
https://wxt.dev/guide/essentials/config/browser-startup.html#persist-data

クッキーはローカルのどこに保存されているのか

開発に必要なCookiesファイルを作成するため、pnpm run devコマンドでChromeを起動し、使用したいサイトでログインをします。すると自動的にローカルにCookiesファイルが作成されます。次にchrome://version/をリンクバーに入力し、現在利用しているChromeの情報が書かれたページに移動します。ページにある「プロフィールパス」という項目に書かれたパスが設定ファイルなのでこのディレクトリを開きます。開いたディレクトリの中にあるCookiesがローカルに保存されているクッキーのファイルです。sqliteで開くことができます。

ひげひげ

devtoolsをデフォルトで起動するようにしたい

無理。この機能はFirefoxしか使えない

runnerオプションにはopenDevtoolsというキーがあるのに開かない。。web-extで提供されている機能のはずなので開くはず。設定ミスなのか、自動で開くのに条件があるのか、wxtのミスなのかは不明。
https://github.com/wxt-dev/wxt/blob/5b557d02246005f7a0aca6ad5991794a3e2db3cb/packages/wxt/src/types.ts#L959

ひげひげ

CSP により fetchでサイトの内容が取得できない

DOMが取得できないエラーが発生して、調査してたらCORSが設定されているためfetchでURLからサイトのDOMを取得することができなかった。同じドメインにアクセスしても、コンテンツスクリプトでfetchを行うとCORSに引っかかる。そこで、host_permissionの追加と、fetchをバックグラウンドスクリプトで行うことにした。

background で
https://qiita.com/wataru86/items/ce461ac7f761c7b97e32

ひげひげ

便利そうなライブラリ

superjson

Set型やDate型などjsonに対応してない型でもserealize/deserializeすることができる

ひげひげ

エラーもconsole.logによるデバッグも何も出力されなくなった時

permissionに書き足すべきものが書き足せてないか確認する。webRequestとか。

ひげひげ

各機能の更新方法

バックグラウンドスクリプトは手動で更新

chrome://extensionsに移動し、♻️ボタンを押して再起動する必要がある。

コンテンツスクリプトは自動更新

エディタで編集してChromeをクリックすると再度読み込みされて更新される。

ひげひげ

サイトへスクリプトの挿入

指定のサイトへのスクリプトの挿入であればコンテンツスクリプトでもいいが、ポップアップで現在のタブを操作するにはこういうのもあるらしい。ポップアップでも使えるAPIの一覧を考えれば、ポップアップで操作できることがわかる。
https://developer.chrome.com/blog/crx-scripting-api?hl=ja

ひげひげ

ポップアップでaタグを開く

このように、target="_blank"を書く必要がある。

<a href={url} target="_blank" >{title}</a>
ひげひげ

PostmanはCORSに引っかからない

CORSはJavaScriptを通してブラウザやwebが異なるオリジンに通信するときに制限をかける。ブラウザじゃないPostmanはCORSの対象外。
declativeNetRequestでヘッダーを書き換えたらいいとか思ってたけどそんな簡単な話ではないのか

クロスオリジンリクエストについての記事
https://ja.javascript.info/fetch-crossorigin

ひげひげ

変更が反映されないとき

一度WXTを停止して、開発用のChromeのウィンドウ閉じなかったら強制終了して、pnpm run dev でWXTを再起動する。

ひげひげ

chrome.* APIはバックグラウンドだけしか使えないものもある。

例えばchrome.webRequest APIはバックグラウンドでしか使えない。そこで一覧を表示して確かめるには、バックグラウンドとコンテンツスクリプトの両方で次のように入力する。

console.log(chrome)

ただし今の段階ではDOMやi18やruntimeはあるものの、使えるものが少ない。なぜかというとmanifest.jsonのpermissionで使えるAPIを宣言してないから。

全部試したいときはこのページにあるパーミッションリストのページをコピペしてchatgptに頼んでmanifest.json用にフォーマットしてもらう
https://developer.chrome.com/docs/extensions/reference/permissions-list

追加してもユーザーに通知がいかないpermissionsもある
https://developer.chrome.com/docs/extensions/develop/concepts/permission-warnings#nowarning

この記事では別の視点から解説している。tabsなら権限が必要だがactivetabなら権限がいらない。
https://qiita.com/tksugimoto/items/0e9ada7efc3b6570e10c

ドキュメント。アラートが表示されるpermissionsの取り扱いについて
https://developer.chrome.com/docs/extensions/develop/concepts/declare-permissions

ひげひげ

複数ページでReactのルートコンポーネントを作成する

popupはデフォルトで作られているからいいとして、オプションページやコンテンツスクリプトで新しくReactページを作るときはどうしたらいいだろうか。

コンテンツスクリプトはGithubにサンプルとして提供されているのでこれを真似したら良さそう。
entrypoints/contentにReactようのファイルを書いている。コンテンツスクリプトは<html>タグごと書くのではなく、<div>など要素単位で挿入するので、index.htmlstyle.cssを作るような大袈裟なことはしなくていい。あれは新しいページを作るものであって、ここで作りたいものは新しい要素。
https://github.com/wxt-dev/examples/tree/main/examples/react-content-script-ui/entrypoints/content

optionsやsidepanelなどはここに書いてあるのをやれば良さそう。
https://wxt.dev/guide/essentials/frontend-frameworks.html#multiple-apps

entrypoints/配下に popupoptionssidepanelなどのディレクトリを作成し、いつも通りindex.htmlApp.tsxmain.tsxstyle.cssを作成する。

共通コンポーネントはルートディレクトリのcomponetsにおき、共通のスタイルやアセットはルートディレクトリのassetsにおく。

📂 {srcDir}/
   📂 assets/          
      📄 tailwind.css     <-- 共有のスタイルやアセット
   📂 components/         
      📄 Button.tsx       <-- 共有コンポーネント(例えばボタンやモーダルなど)
   📂 entrypoints/        <-- 各エントリポイント用のフォルダ
      📂 popup/           <-- ポップアップ用のUI
         📄 index.html
         📄 App.tsx
         📄 main.tsx
         📄 style.css
      📂 options/         <-- オプションページ用のUI
         📄 index.html
         📄 App.tsx
         📄 main.tsx
         📄 style.css
         📄 router.ts     <-- ルーティングが必要な場合
ひげひげ

Mantine UI を使う

なんかWXTのexampleで使われていたコンポーネントライブラリ。MUIが何となく嫌いだからこっち使おう。コンポーネントの種類にも不満なし。
このリポジトリを参考に。
https://github.com/wxt-dev/examples/tree/main/examples/react-mantine

Loader の children propsが良さそう

ロード中にオーバーレイをして別のコンテンツが表示される。ダウンロード中は進行中のファイル名などを表示しておくと使いやすさが増す

ひげひげ

Messaging

メッセージを受け取るときは message.dataで取り出せる

upLoadFileで送ったデータはコールバック関数の引数のdataプロパティをアクセスして、それからアクセスできる。

    const dl = onMessage1('uploadFile', message => {
        message.data.filename
    })

Protocol map functions should be defined with a single parameter, data. To pass more than one argument, make the data parameter an object instead!

https://webext-core.aklinker1.io/messaging/protocol-maps

sendMessageは非同期だが、onMessageは同期関数。その理由はなんかonMessageをasync/awaitで実装するとだめらしい。
https://www.mitsuru-takahashi.net/blog/chrome-extension-response/

ひげひげ

バックグラウンドからコンテンツスクリプトのコンポーネントにメッセージを送りたい

正確には contentscript(send) -> background(on) でメッセージを送り、ループの中で
background(send) -> contentscriptのコンポーネント(on) というメッセージのループが起こっている。イメージとしては下のコードのような感じ

    onMessage1('ping', async (dlList) =>  {
        console.log("pingを受信")
        for (const item of dlList.data) {
            console.log(item)
            const msg = await sendMessage1('uploadFile', item)
        }
        return 'ダウンロード完了'
    });

できるかなって思ったけど、Error: Could not establish connection. Receiving end does not exist. at wrappedSendMessageCallback (chrome-extension://c…ckground.js:1330:26)というエラーがでた。ループの中でログを取ると、2行めのログと、1回目のループのsendの直前のログは取れている。ということは、uploadFileメッセージをsendしたのに、正しく受信onができてないのが原因だ。

コンポーネントでどのようにonMesageで受信すればいいかを知る必要がある。content->background間のメッセージ送受はできたが、background->conetnt のコンポーネントのやり方を知らない。やり方があるのか?

普通にエラーメッセージでググればよかった。どうやらこのエラーメッセージは Chrome extension固有のエラーらしい

Messageのパッケージのドキュメントに書いていた。コンテンツスクリプト->バックグラウンドは普通に情報送るだけだが、バックグラウンド->コンテンツスクリプトはtabのIDを指定する必要があるらしい。今回はアクティブタブで送信する。
何ならChromeのドキュメントにも書いていた。アクティブタブの取得方法が参考になるからChromeのドキュメントも必見
https://webext-core.aklinker1.io/messaging/installation#sending-messages-to-tabs
https://developer.chrome.com/docs/extensions/reference/api/tabs?hl=ja#messaging

ひげひげ

onMessageは関数を分割するな

このように書いたら型を決めるのが面倒だった。onMessageのコールバックとして直接書いたら型推論してくれるが、関数として切り出したら型推論してくれずに自分で型を書く必要がある。それが面倒だから直接かこう

const handler = // ...
onMessage('msg', handler)
ひげひげ

ダウンロードリストを作りたい

ダウンロードするリストを入れておいて、

  • メッセージ受信の中に、ループのメッセージ送信とループ終了のメッセージ送信があるのでちょっと複雑。
  • 新しい順にするためにmapで取り出す前に逆順にしておく。setStateでnew, ...prevとする方法もあるが、普通はprevが先に来るのでそう書いてるのを忘れてしまいそう。
  • 全てのダウンロードが終わったらuploadFinishedメッセージを送り、finishedstateをtrueに反転させる。そしたら条件分岐の1つ目に引っかかるので、全てがfinished=trueとした場合のコンポーネントが適用される
  • 先頭の要素(最新の)をfinished=falseのコンポーネントを適用し、ダウンロード中のものだけ別の表示にする
  • chrome.donwload.onChange イベントでダウンロードしたファイルのサイズとかがわかるので、ファイルが大きいときは使うと便利かもしれない。
background.ts
    onMessage('pushDownloadQueue', async (dl) =>  {
        const [activeTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
        const id = await downloadStart(dl.data);
        const targetFilename = dl.data.targetFilename
        console.log(dl)
        console.log("ダウンロードキューに追加", targetFilename)
        if (id !== -1) {
            await sendMessage("downloadStatusStarted", { id, targetFilename, state: "in_progress" }, activeTab.id);
        }
    });

    chrome.downloads.onChanged.addListener(async (delta) => {
        const { id, state } = delta;
        // chrome.downloadsはbackgroundでしか使えないのでactiveTabのIDを取得する必要がある。
        const [activeTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });

        if (allDownloads[id]) {
            // downloadDeltaのうち、stateが含まれているものだけを対象。complete か interrupted
            if (state !== undefined && state.current !== undefined) {
                await sendMessage("downloadStatusUpdated", { id, state: state.current }, activeTab.id);
            }
        }
    })
utils/download.ts
    let fileCount = 0

    // 現在は支援金額をみたしている投稿だけ表示している。
    for (const postUrl of postUrls) {
        const postJson = await fetchData(postUrl);
        const post = extractPostData(postJson); // fetchでJSON取得直後に不要なキーを削除したPost型に変換
        // 支援金額た足りない場合、 isRestricted が true
        if (post.isRestricted) {
            console.warn(`支援金額が足りないためコンテンツを取得できませんでした。title:『${post.title}`)
            continue;
        }
        fileCount += await pushPostToDownloadQueue(post)
    }
    const profileJson = await fetchData(profileAPIUrl)
    const profile = extractProfileData(profileJson);
    fileCount += await pushProfileToDownloadQueue(profile);

    const plansJson = await fetchData(plansAPIUrl);
    const plans = extractPlansData(plansJson['body']);
    fileCount += await pushPlansToDownloadQueue(plans);

    const [activeTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
    await sendMessage("fileCount", fileCount, activeTab.id);
ProgressArea.tsx

    useEffect(()     => {
        // 各ファイルのダウンロードが開始した直後に受け取るメッセージ。10個ファイルがあれば10回メッセージを受け取る
        onMessage('downloadStarted', (msg) => {
            const { targetFilename, index, maxNum } = msg.data;
            setIndexPair({ index, maxNum: maxNum });
        });

        // ダウンロード開始に成功した直後。
        onMessage("downloadStatusStarted", (msg) => {
            const { id, targetFilename, state} = msg.data;
            setAllDownloads((prev) => ({
                ...prev,
                [id]: { targetFilename, state },
            }));
        })

        // ダウンロード進行状況が更新された直後。
        onMessage("downloadStatusUpdated", (msg) => {
            const { id, state } = msg.data;
            setAllDownloads((prev) => ({
                ...prev,
                [id]: { ...prev[id], state },
            }));
        })


            <Progress.Root size="20">
                <Progress.Section value={progress.percent} color="green">
                    <Progress.Label>(`{progress.label}`)</Progress.Label>
                </Progress.Section>
            </Progress.Root>

ひげひげ

DevToolsを追加したい

Chrome ウェブストアからReact Devtools をインストールしても、サイトのコンポーネントを調べることはできるが、ポップアップやコンテンツスクリプトで挿入したコンポーネントを調べることはできない
react-devtoolsは他の拡張機能と通信できるように作られてないから。拡張機能どうしで通信してデータを送受信することはできるが、react-devtoolsはそのように作られていない。

別途パッケージで入れる必要がある。ちなみにrunner chromiumArgsでChromeのデータを保存する設定をしているとインストールした拡張機能も保持することができる

WXTのIssueにもある
https://github.com/wxt-dev/wxt/issues/1144

これはCRXJSの解決策なので無理か?上のリンクの上の説明を試したがviteがインストールされてないとかでダメだった。同リンクのpatchを使った方法で試してみたい

https://github.com/crxjs/chrome-extension-tools/discussions/752

https://stackoverflow.com/questions/67651633/how-to-add-react-devtools-for-chrome-extension-development

ひげひげ

chromeAPI はPromiseベースを積極的に使っていく

V3からはAPIがPromiseベースになったのが多いので、コールバックではなくPromiseを使っていく。ただし、ChatGPTの情報はPromiseではなくコールバックを出力することがあるので、そのときはドキュメントを読んでPromiseが使えるかどうか調べる。Manifest V3 ではそのAPIはPromiseが使えるよ、というと修正してくれる。

ひげひげ

似たようなメッセージ名が増えると、引数に重複があるから一つにまとめたくなる衝動と、そのメッセージで何の値を更新するのか忘れてしまって中途半端に触ってエラーになる。

            if (id !== -1) {
                await sendMessage("downloadStatusStarted", {id, targetFilename, status: "in_progress"}, tab.id);
                await sendMessage('downloadStarted', {targetFilename, index, maxNum }, tab.id)
            }
ひげひげ

型のオートインポートは使わなくてもいいかな

.wxt/types/imports.d.tsdeclare globalに書き込まれている。型を定義しても自動で反映されない時がある。
Command推してクリックすると参照元にジャンプできるが、ジャンプ先がtype.tsではなくimpots.d.tsになる
逆にtype.tsではどこからもインポートされてないと表示されるので、不要な型を削除したり、型から参照元を辿ることができない
それを捨てて得られるのが1行のインポートを書かなくていいことだからあまりメリットない

ひげひげ

messaging パッケージのエラー

Uncaught (in promise) Error: Could not establish connection. Receiving end does not exist.
受信ができてないというエラー。try-catchで囲んでエラーがどちらがわで出ているか確認する。
自分の場合、バックグラウンドからコンテンツスクリプトへメッセージを送るときにtabIDを渡し忘れたエラーだったので、これが送信側のエラーなのか受信側のエラーなのかはわからない。

ひげひげ

chrome.download では dirname と filenameに分けなくていい

ダウンロード先はfullpathにしておく。fullpathに / が含まれていると自動でディレクトリを作ってくれる。

ひげひげ

messaging api で型が違ってもエラーが起きなかった

具体的なことを忘れたが、setStatusと、メッセージの型で違う名前のものを使っててエラーが出なかったことがある。気をつけねば

ひげひげ

ダウンロード画面を作るとき、stateで配列を分けておく

こんなふうに。

    useEffect(() => {
        setInterruptedDownloads(allDownloads.filter(dl => dl.state === "interrupted"));
        setInProcessDownloads(allDownloads.filter(dl => dl.state === "in_process"));
        setCompleteDownloads(allDownloads.filter(dl => dl.state === "complete"));
    }, [allDownloads]);



        <ScrollArea h={250} type="always" offsetScrollbars scrollbarSize={14} scrollHideDelay={2000}>
            <Flex direction="column" gap="sm">
                {allDownloads.length === 0 ? (
                    <Text  mt="md" c="dimmed">ダウンロードを開始します</Text>
                ) : (
                    <>
                        {interruptedDownloads.map(dl => (
                            <InterruptedDownload key={dl.id} targetFilename={dl.targetFilename} />
                        ))}
                        {inProcessDownloads.map(dl => (
                            <InProcessDownload key={dl.id} targetFilename={dl.targetFilename} />
                        ))}
                        {completeDownloads.map(dl => (
                            <CompletedDownload key={dl.id} targetFilename={dl.targetFilename} />
                        ))}
                    </>
                )}
            </Flex>
        </ScrollArea>

ひげひげ

content script でfetchできたのにbackgroundだとエラーが起こった

コンテンツスクリプトでは開いているウィンドウとコンテンツスクリプトでは、認証情報が共有されてるくさい。ブラウザ上でログインしていると、コンテンツスクリプトでも認証が必要なサイトにアクセスできる。しかしバックグラウンドはタブと別プロセスで動いているため、画面上で認証していてもバックグラウンドでは認証している状態ではない。

ひげひげ

activeTab を取得したいときは開きたいページをクリックして再起動する

WXTで開発中、自動で再度読み込みが起こるゆえ、欲しいブラウザがアクティブになってない状態でアクティブタブを取得しようとして、思った通りのタブの情報が取得できずに詰む。
再起動が起こっているなと思ったら目標のタブをクリックしてアクティブにすると間に合うのか?
挙動は不明だが、tabの情報が取得できないと考えて、Command R で再起動しておきたい

ひげひげ

バックグラウンドから認証ページへ移動仕方がわからない

クッキーが違うのか、ヘッダーに漏れがあるのか、何なのか。
できるだけコンテンツスクリプトで必要なメッセージを受信して、できないものをダウンロードするとかか?

バックグラウンドのリクエストと、コンテンツスクリプトのリクエストを照らし合わせてよくみて、何が違うのかをみて、なるべく同じに合わせるとかしたらいいのか

RapidAPIでできることと、できないことを見る。

ひげひげ

ページの変更を検知

chrome.tabs.onUpdatedイベントリスナーで検知することができた。reactで作られたサイトのページ移動も検出できる。

    // ページ移動したときURLを送信する
    browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
        console.log(changeInfo)
        // ページ移動に完成したら {status: 'complete'} が発生する。そのときのtabのurlを送信する
        // if (changeInfo.status === "complete" && tab.url) {
        if (changeInfo.url && tab.url) {
            sendMessage("changedUrl", tab.url, tab.id);
        }
    })

React Router

これは拡張機能というより、フロントエンド開発でReact Router を使っている場合の検知

  • ページのリロード
  • タブやウィンドウを閉じる
  • ブラウザーバック
  • リンクをクリック

https://dev.to/eons/detect-page-refresh-tab-close-and-route-change-with-react-router-v5-3pd

ひげひげ

いつバックグラウンドが起動するのか調べる

chatgptによると

  • ブラウザの起動時
  • イベントが発生した時
  • メッセージの受信時
  • ユーザーアクション(ポップアップが開かれたり、コンテキストメニューからアクションを選択された時)
  • アラームAPIがトリガーされた時
  • 外部イベント(ウェブリクエスト、通知、ネットワーク変化)

などがある。要するにバックグラウンドはイベント駆動で起動したり停止したりする。確かデフォルトでバックグラウンドが生きている時間は5分。

ひげひげ

webページ滞在中に現在のURLを取得するために2つの処理が必要

  1. 現在のURLを取得(1度)
  2. ページ更新時にURLを取得(更新するたび)

2についてはバックグラウンドでchrome.tabs.onUpdatedで取得する。

ひげひげ

環境変数について

https://wxt.dev/guide/essentials/config/environment-variables.html

  • Viteと同じ書き方
  • ファイルは .env .env.local など複数のパターンに対応している
  • 命名規則としてWXT_VITE_から始まるように書く必要がある
  • .envは初期から.gitignoreに書かれているわけではないので手書きする必要がある
  • 呼び出すときはimport.meta.env.XXXX
  • manifestファイルに環境変数を埋め込みたいときは別の書き方をする。WXTは設定ファイルをロードするまで.envファイルの内容を読み込むことはできない。なので、manifestを関数にすることで、.envファイルを読み込んだ後でmanifestを作成している。
    https://wxt.dev/guide/essentials/config/environment-variables.html#manifest
  • 上の理由からrunnerでstart_urlに環境変数を指定したかったが多分無理っぽい。それともdefineRunnerでrunnerを作成したら使えるか?
ひげひげ

createIntegratedUi() で anchorを指定しても要素が選択されないときはSPAであることを疑う。
こんなふうに、alert()を差し込むと要素がそもそも作られてないことがわかるだろう。

alertが表示された時点でほとんどのUIが組み立てられていなければそのサイトはSPAだ

content.tsx
import ReactDOM from 'react-dom/client';
import App from './App.tsx';

export default defineContentScript({
  matches: ['<all_urls>'],

  main(ctx) {
+   alert('hello');
    
    const ui = createIntegratedUi(ctx, {
      position: 'inline',
      anchor: '.classname', // ここを必死に変えても何も表示されないならSPAを疑う。
      ...
    });

    ui.mount();
  },
});
ひげひげ

utils/storage.tsでストレージに保存する値を定義して、fallbackを設定すると、呼び出し元でnullチェックする必要がなくなる。nullの場合fallbackで初期値を返すから。

import { blockedUrl as lBlockedUrl } from '../utils/storage';

export default defineBackground(() => {
  async function updateRules() {
    const existingRules = await browser.declarativeNetRequest.getDynamicRules();
    const removedRuleIds = existingRules.map( rule => rule.id);
    const blockedUrl = await lBlockedUrl.getValue(); // 使用時にnullチェックする必要なし

ひげひげ

コンソールで強制終了したときの挙動

これで調べる。

background.ts
  browser.runtime.onInstalled.addListener(async(details) => {
    console.log(`${new Date()}`);
    console.log(details);
    await updateRules();
  })

chromiumArgsを設定して2回目以降の場合は更新時(update)と認識される。
初回、もしくはchromiumArgsを指定しない場合はインストール時(install)と認識される。

chromiumArgsを設定したらブラウザに色々情報が保存される。なので更新状態と見なされる。しかし、インストール時と更新時の両方でonInstalledイベントは発生しているので、特に特に気にせずコードを書ける。