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()
を呼び出すときに、fallback
やinit()
を設定してもnullチェックは必要。そこは仕方がない。
storage.tsから呼び出して、fallbackを設定してたらnullchekは不要
initだけならnullcheck必要
useEffect
を使う
Storageの内容をポップアップに表示するときは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>
</>
)
useState
の初期値をstorage
から取り出した値にする
storage
の値を画面に表示するのはややこしい。愚直にやろうとするとuseState
とuseEffect
とstorage
を組み合わせた処理を作らなくちゃならない。WXTはReactとStorageを連携する便利なHooksがないからきつい。PlasmoならReact Hook APIというのが提供されているから次のようにReactの文法に沿った方法で書ける。
const [hailingFrequency] = useStorage("hailing")
...
{hailingFrequency}
これを見てPlasmoに乗り換えたくなったが、Plasmoはドキュメントがしっかりしてないから調べるのが大変そう。さすがに大変だから調査が必要
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の考え方はあまり好きでないと書いていた。
作者が言うには上図のようにデータ共有するときは3つの考え方がある。Message APIは3番目の感が型。作者は1番目のBackground as an "API"
という、バックグラウンドをAPIとして活用する方法が好ましいと考えている。関係あるか分からないが、entrypints のドキュメントではBackgroundScriptが一番上に記載されてあるし、BackgroundScriptだけ1つしかファイルを設定できない。
また、Background as an "API"
を実現するためにtrpc-chrome
というライブラリを使うといいと勧めている。一方、Messaging API のラッパーのライブラリを使えるようになっている。
WXT で作られた拡張機能
一覧
Youtubeの画像スクショ
日本人が作っている。コメントが多いので参考になる
YoutubeのサムネをEagleに保存する
クッキー編集
OneTabみたいなもの
結構大きめのコードなので参考になる
手の込んだような小さいような拡張機能
figmaのなんかの拡張機能
declarativeNetを使っている
WXT で作られた拡張機能
コンポーネントは自動でインポートされる
WXTではReactのコンポーネントを明示的にインポートせずとも自動インポートする機能がある。そのためにはルートディレクトリにcomponents
ディレクトリを作成しておき、コンポーネントのファイルを配置すると可能になる。わざわざimport '@/compoenent/Main.tsx'
などと書かなくていい。
新しく追加したコンポーネントはすぐに認識しないので、一度Control + C
でWXTを停止させて再び起動する。
画像のように読み込みエラーが出るときがある
画像のようにコンポーネントに読み込みエラーがでると、WXTを再起動しよう
storage
で defineItem
の返り値の型は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>
</>
)
}
storage
のwatch
をReactのどこに記述すべきか
失敗例。関数コンポーネントのトップレベルに書いてしまった
こんなふうに、storage.watch
をuseEffect
を使わずに書いていたがReactの書き方としては良くないらしい。ChatGPTがそう言ってた。
export default function App() {
/* state宣言とかの記述 */
storage.watch<boolean>('local:isFlag'), (new, old) { /* 変化の検出時に行う処理の記述 */ };
return <></>
}
これのミスはuseEffect
を使わなかったことだ。useEffect
を使うことでメリットは3つある
- コンポーネントがマウントされるたびに
storage.watch
が再登録されてしまい、予期せぬ挙動を引き起こす可能性がある - クリーンアップ関数が書ける
- ReactでStateを監視・更新する際には
useEffect
を行うのが一般的
1について、確かにwatch
の中に検出した値をalert
で表示するような記述をしてたら複数回表示されるという原因不明のエラーに陥った。useEffectの中にwatch
を書くと治った!
2について、watch
の返り値は監視をやめるオブジェクトが返ってくる。これをそのままunwatch
という変数名に入れて、クリーンアップ関数としてそのまま使うことができる。製作者はuseEffect
を使うことを考慮に入れてくれていたのかもしれない。
3について、これは知らなかった。useEffect
についてはかなり議論されてそうだから、使うタイミングについて調べる必要がある
storage.watch
はuseEffect
に書く
useEffect
にasync
関数は書けないので関数を新たに定義する
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);
})()
}, []);
watch
の有効範囲は謎だが、少なくともポップアップが開いている状態だと、別のウィンドウの別のタブを開いてもstorageの変更を検出できていた。watch
をバックグラウンドスクリプトやコンテンツスクリプトで呼び出すと、もう少し長生きできるのかもしれない。
初回レンダーだけ初期値を表示させてそれ以降は書き換え可能
storage
に表示された値をデフォルト値としてフォームの入力欄に表示させたい。そこで次のようにinput
のvalue
にstateの変数を代入していた。こう書くと、フォームに何を入力してもstateに保存された値に書き戻されてしまう。
const [todo, setTodo] = useState<string>("");
<input type="text" id="todo" placeholder="業務内容" value={todo} />
useEffect
ではDOMの値にアクセスできないので、useEffect内でinput.value = "初期値"
のように変更はできない。そもそもDOMの値を直接変更することは非推奨!!useState
やuseRef
などを使って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
から取得した値を表示する
初期値として
storage
のプロミスチェーン内でsetState
方法1 storage系のメソッドはPromiseを返すから、storage.getItem()
で値を取得してから、then()
の中でsetState
を使ってUIの更新を行う。
storage.getItem('local:showFps').then((v) => {
setShowFps(v);
});
setState
内でstorage.
の
方法2 // 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
に保存された内容を画面に出力するのだからこの順番は正しい。
変数名の衝突を避けるため、上のどちらかの方法でやった方が綺麗にかける。似たような名前が
-
storage
のキー名 -
utils/storage
にでdefineItem
している変数名 -
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);
}
})();
// 省略
}, []);
createIntegratedUI
でReact.createRoot
を使ってルートコンポーネントを新しく作っているコードがあった。この方法でポップアップ以外でもReactの新しいルートコンポーネントを作れる。Reactタブを開こう。
この人のようにコンポーネントを読み込んで使うこともできる。
createIntegratedUi
は思ったよりも薄い関数だった。しかしTypeScriptの型の文法がよくわからんのでこれもよくわからん。しかし完全にわからないというわけではない。
position
ソースコード
position
の入力値はinline
、overlay
、modal
の3つの値がある。これら3つはそれぞれ型として定義されている。inline
はContentScriptInlinePositioningOptions
、overlay
に対応するのはContentScriptOverlayPositioningOptions
、modal
に対応するのはContentScriptModalPositioningOptions
。
それぞれの型はCSSセレクタっぽい見た目。
anchor
引数のanchor
はCSSセレクタ、XPath、要素、もしくはこの3つのいずれかを戻り値とする関数を入れる。戻り値はいずれの場合もElement
になる。CSSセレクタとXPathは文字列として渡し、要素はquerySelector()
などで取得したElement
型で渡す。ソースコードはgetAnchor()
で定義されている。
ソースコード
渡されたのがCSSセレクタ、XPath、要素、関数であっても対応できるようにうまく書いている。
append
anchor
要素の位置に対して、UIをどこに配置するかを指示するプロパティ。画像のように配置される。
ソースコード
anchor
は上で書いたようにElement
型の値。anchor
要素に対してappend()
、prepend()
、replaceWith()
などのメソッドを使って要素を挿入して、anchor
を基準にして位置を指定している。
ログイン情報を保存して次回の起動時に利用する
WXTはMozillaが提供するwxt-web
を利用しているので、dev
コマンドで実行すると、履歴やクッキーが保存されていない状態でブラウザが起動します。データを残さない状態を保てるのは便利ですが、ログインが必要なサイトの拡張機能を作るときには不都合。そこでWXTの開発時に永続データを読み込む方法を紹介する。
wxt.config.ts
にrunner
というオプションを追加した次のコードを追記する。
import { defineConfig } from 'wxt';
runner: {
chromiumArgs: ['--user-data-dir=./.wxt/chrome-data'],
}
});
次にブラウザを開いて目的のサイトでログインをすると、.wxt/chrome-data
に多くの設定ファイルが作成されていることがわかります。次回以降はWXTで開いたブラウザはこれらのファイルを読み書きするので再度ログインする必要がなくなります。.wxt
ディレクトリはデフォルトで.gitignore
に指定されているので、ログインデータなどが他の人に共有されることはありません。また、クッキーは.wxt/chrome-data/Default/Cookies
のファイルに保存されています。SQLiteビュワーを使って確認してみてください。
クッキーはローカルのどこに保存されているのか
開発に必要なCookies
ファイルを作成するため、pnpm run dev
コマンドでChromeを起動し、使用したいサイトでログインをします。すると自動的にローカルにCookies
ファイルが作成されます。次にchrome://version/
をリンクバーに入力し、現在利用しているChromeの情報が書かれたページに移動します。ページにある「プロフィールパス」という項目に書かれたパスが設定ファイルなのでこのディレクトリを開きます。開いたディレクトリの中にあるCookies
がローカルに保存されているクッキーのファイルです。sqliteで開くことができます。
devtoolsのサイドバーって何だろ
ドキュメントのページをコピペしたが動かなかった。
わからないが、こんなふうにReactのコンポーネント作ってやっている人がいる。
各要素のデバッグ方法が書かれてた
Mozillaのドキュメントに
バックグラウンド、コンテンツスクリプト、ポップアップなど他のいろんなやつのデバッグ方法が書かれている
devtoolsをデフォルトで起動するようにしたい
無理。この機能はFirefoxしか使えない
runnerオプションにはopenDevtools
というキーがあるのに開かない。。web-extで提供されている機能のはずなので開くはず。設定ミスなのか、自動で開くのに条件があるのか、wxtのミスなのかは不明。
fetch
でサイトの内容が取得できない
CSP により DOMが取得できないエラーが発生して、調査してたらCORSが設定されているためfetch
でURLからサイトのDOMを取得することができなかった。同じドメインにアクセスしても、コンテンツスクリプトでfetchを行うとCORSに引っかかる。そこで、host_permission
の追加と、fetch
をバックグラウンドスクリプトで行うことにした。
background で
便利そうなライブラリ
superjson
Set型やDate型などjsonに対応してない型でもserealize/deserializeすることができる
trpc でメッセージ通信
ポップアップとバックグラウンドを繋ぐサンプル
エラーもconsole.logによるデバッグも何も出力されなくなった時
permissionに書き足すべきものが書き足せてないか確認する。webRequestとか。
各機能の更新方法
バックグラウンドスクリプトは手動で更新
chrome://extensions
に移動し、♻️ボタンを押して再起動する必要がある。
コンテンツスクリプトは自動更新
エディタで編集してChromeをクリックすると再度読み込みされて更新される。
サイトへスクリプトの挿入
指定のサイトへのスクリプトの挿入であればコンテンツスクリプトでもいいが、ポップアップで現在のタブを操作するにはこういうのもあるらしい。ポップアップでも使えるAPIの一覧を考えれば、ポップアップで操作できることがわかる。
ポップアップでaタグを開く
このように、target="_blank"
を書く必要がある。
<a href={url} target="_blank" >{title}</a>
PostmanはCORSに引っかからない
CORSはJavaScriptを通してブラウザやwebが異なるオリジンに通信するときに制限をかける。ブラウザじゃないPostmanはCORSの対象外。
declativeNetRequestでヘッダーを書き換えたらいいとか思ってたけどそんな簡単な話ではないのか
クロスオリジンリクエストについての記事
変更が反映されないとき
一度WXTを停止して、開発用のChromeのウィンドウ閉じなかったら強制終了して、pnpm run dev でWXTを再起動する。
chrome.* APIはバックグラウンドだけしか使えないものもある。
例えばchrome.webRequest APIはバックグラウンドでしか使えない。そこで一覧を表示して確かめるには、バックグラウンドとコンテンツスクリプトの両方で次のように入力する。
console.log(chrome)
ただし今の段階ではDOMやi18やruntime
はあるものの、使えるものが少ない。なぜかというとmanifest.jsonのpermission
で使えるAPIを宣言してないから。
全部試したいときはこのページにあるパーミッションリストのページをコピペしてchatgptに頼んでmanifest.json用にフォーマットしてもらう
追加してもユーザーに通知がいかないpermissionsもある
この記事では別の視点から解説している。tabsなら権限が必要だがactivetabなら権限がいらない。
ドキュメント。アラートが表示されるpermissionsの取り扱いについて
複数ページでReactのルートコンポーネントを作成する
popupはデフォルトで作られているからいいとして、オプションページやコンテンツスクリプトで新しくReactページを作るときはどうしたらいいだろうか。
コンテンツスクリプトはGithubにサンプルとして提供されているのでこれを真似したら良さそう。
entrypoints/content
にReactようのファイルを書いている。コンテンツスクリプトは<html>タグごと書くのではなく、<div>など要素単位で挿入するので、index.html
やstyle.css
を作るような大袈裟なことはしなくていい。あれは新しいページを作るものであって、ここで作りたいものは新しい要素。
optionsやsidepanelなどはここに書いてあるのをやれば良さそう。
entrypoints/配下に popup
、options
、sidepanel
などのディレクトリを作成し、いつも通りindex.html
、App.tsx
、main.tsx
、style.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 <-- ルーティングが必要な場合
コンテンツスクリプトの画像の読み込み
ドキュメントによるとコンテンツスクリプトから画像を読み込むには1行追加する必要があるが、Reactを使うと特別なことしなくても画像を読み込めた。理由はわからん。
Mantine UI を使う
なんかWXTのexampleで使われていたコンポーネントライブラリ。MUIが何となく嫌いだからこっち使おう。コンポーネントの種類にも不満なし。
このリポジトリを参考に。
Loader の children propsが良さそう
ロード中にオーバーレイをして別のコンテンツが表示される。ダウンロード中は進行中のファイル名などを表示しておくと使いやすさが増す
Tabler Icons のアイコンが上にずれている
アイコンを<Text>で囲み、Textにstyle={{ verticalAlign: "middle" }}
を適用する
<Flex justify="flex-start" align="flex-start" direction="row" className="progress-flex" >
<Text style={{ verticalAlign: "middle" }}><IconLoader className="loading" size="1em"/></Text>
<Text size="md">投稿4</Text>
</Flex>
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!
sendMessageは非同期だが、onMessageは同期関数。その理由はなんかonMessageをasync/awaitで実装するとだめらしい。
バックグラウンドからコンテンツスクリプトのコンポーネントにメッセージを送りたい
正確には 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のドキュメントも必見
onMessageは関数を分割するな
このように書いたら型を決めるのが面倒だった。onMessageのコールバックとして直接書いたら型推論してくれるが、関数として切り出したら型推論してくれずに自分で型を書く必要がある。それが面倒だから直接かこう
const handler = // ...
onMessage('msg', handler)
ダウンロードリストを作りたい
ダウンロードするリストを入れておいて、
- メッセージ受信の中に、ループのメッセージ送信とループ終了のメッセージ送信があるのでちょっと複雑。
- 新しい順にするためにmapで取り出す前に逆順にしておく。setStateで
new, ...prev
とする方法もあるが、普通はprevが先に来るのでそう書いてるのを忘れてしまいそう。 - 全てのダウンロードが終わったら
uploadFinished
メッセージを送り、finished
stateをtrueに反転させる。そしたら条件分岐の1つ目に引っかかるので、全てがfinished=true
とした場合のコンポーネントが適用される - 先頭の要素(最新の)を
finished=false
のコンポーネントを適用し、ダウンロード中のものだけ別の表示にする - chrome.donwload.onChange イベントでダウンロードしたファイルのサイズとかがわかるので、ファイルが大きいときは使うと便利かもしれない。
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);
}
}
})
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);
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にもある
これはCRXJSの解決策なので無理か?上のリンクの上の説明を試したがviteがインストールされてないとかでダメだった。同リンクのpatchを使った方法で試してみたい
useStateの初期値について詳しく
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.ts
のdeclare 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でできることと、できないことを見る。
externaly_connectable ってなんだ
manifest.jsonのキー
ページの変更を検知
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 を使っている場合の検知
- ページのリロード
- タブやウィンドウを閉じる
- ブラウザーバック
- リンクをクリック
いつバックグラウンドが起動するのか調べる
chatgptによると
- ブラウザの起動時
- イベントが発生した時
- メッセージの受信時
- ユーザーアクション(ポップアップが開かれたり、コンテキストメニューからアクションを選択された時)
- アラームAPIがトリガーされた時
- 外部イベント(ウェブリクエスト、通知、ネットワーク変化)
などがある。要するにバックグラウンドはイベント駆動で起動したり停止したりする。確かデフォルトでバックグラウンドが生きている時間は5分。
webページ滞在中に現在のURLを取得するために2つの処理が必要
- 現在のURLを取得(1度)
- ページ更新時にURLを取得(更新するたび)
2についてはバックグラウンドでchrome.tabs.onUpdated
で取得する。
環境変数について
- 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だ
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チェックする必要なし
コンソールで強制終了したときの挙動
これで調べる。
browser.runtime.onInstalled.addListener(async(details) => {
console.log(`${new Date()}`);
console.log(details);
await updateRules();
})
chromiumArgsを設定して2回目以降の場合は更新時(update
)と認識される。
初回、もしくはchromiumArgsを指定しない場合はインストール時(install
)と認識される。
chromiumArgsを設定したらブラウザに色々情報が保存される。なので更新状態と見なされる。しかし、インストール時と更新時の両方でonInstalled
イベントは発生しているので、特に特に気にせずコードを書ける。