罠(踏んだもの、踏みそうなもの)

作りながら実際に踏んだ穴と、いまどう避けているか。新しく踏んだら足す。 「なぜそうなっているか」が分からなくなったとき、ここを読む。

ページ側

about:* / chrome:* には WebExtension の content script が入らない(stock Firefox)

一番大きかった罠。最初の drop は「特権 built-in WebExtension + content_scripts + experiment API」で、 about:newtab に一行も届かなかった。built-in の newtab actor も同じで、誰も気づいていなかった (ページのデータは古い JSActor が運んでいたので、動いて見えていた)。 mozillaAddons で privileged にしても、host permission に about:newtab* を足しても入らない。 https://example.com/* を matches に足すとそこでは走る(= about: だけが閉じている)。

いま: Mozilla 自身の about:newtab add-on と同じ形。xpi は入れ物(manifest は id / version / hidden だけ)、 ページへの道は JSWindowActor(actor.json + parent.sys.mjs + child.sys.mjs)。 子が content.jsloadSubScriptwindow / document 付きの scope に読むので、drop の書きかたは変わらない。 noraneko の webext-actors/README.md「addon 式が駄目だった理由」にも同じことが書いてある。

入れ替えた bytes が、その session から見えない(版が同じときだけ)

版が変われば、いまは見える。 手元の置き場に版が入るようになった(noraneko 3691c27):

<profile>/noraneko-drops/<uuid>/<版>/<drop>.xpi
<profile>/noraneko-drops/<uuid>/deps/<名前>/<版>/lib.xpi

前はどの版も同じ file 名に上書きしていたので、その session で前の版が一度でも開かれていると jar の handle が生きていて、新しい別名が前の bytes を指した。二度踏んだ: 2026-09-08 は runtime 0.4.0 を入れたのに 0.3.0 の wasm が動き、2026-09-09 は webpanel 1.3.0 に std 1.1.0 の lib.js が読まれて std.pref is undefined。どちらも立て直すまで分からなかった。

残っているのは、版が同じまま中身だけ変えたとき(手元で組み直したときだけ起きる。registry は 判を押した版の bytes を変えさせない)。そのときは版を上げるか、ブラウザを立て直してから見る。

古い形の xpi は、新しい noraneko に入らない

actor.json が無い xpi は NoraActors.readActorJson で落ちる。逆(新しい xpi を古い noraneko に)は、 content script が about: に入らないので、入るけど何も起きない。 道具(tooling/)を替えたら、配っている drop を全部 workflow_dispatch で置き直す。

resource:// の先が jar:file: だと fetch() は NetworkError

xpi の中の actor.jsonfetch("resource://<alias>/actor.json") で読もうとして落ちた。 NetUtil.asyncFetch({ uri, loadUsingSystemPrincipal: true }) なら読める。importESModuleloadSubScript は平気。

importESModulejar:file: を信用しない

"System modules must be loaded from a trusted scheme"。だから入れる側が resource://noraneko-drop-<uuid>-<版>/ を xpi の root に張り、xpi の中の parent.sys.mjs / child.sys.mjs はその URL を指す(build-drop.rb が書き換える)。 setSubstitutionWithFlags(..., ALLOW_CONTENT_ACCESS) で。content process にも同じ別名が届く。

別名に版を入れる理由(と、そこに残っている穴)

module cache は URL 単位。同じ session で drop の版を替えたとき、別名が同じだと古い module が残る。 だから noraneko-drop-<uuid>-<版>

版が同じで中身だけ違うときは、まだ効かない。 2026-09-08 まで版には commit の時刻が四つ目として 付いていた(1.2.0.202609081330)が、あれも commit ごとにしか変わらないので、commit せずに組み直す 夜の輪では最初から同じだった。しかも WebExtension の版は一つが 9 桁までで、12 桁の時刻は毎回 Firefox に警告されていた。なので外した(scripts/build-drop.rb)。

いまの約束は「中身が変われば版を上げる」(台帳の門)。配ったものについてはそれで足りる。 残るのは手元の輪だけ — 同じ版のまま組み直して入れ直したら、その session では古い module が動きうる。 気になるなら noraneko 側(Drops.sys.mtsresAlias)で xpi の sha を別名に足すのが素直。

about:newtab は先読みされている

Firefox は次の新しいタブを裏で先に作っておく。drop を入れた直後の一枚は、入れる前に作られたもののことがある。 「入れたのに出ない」は、もう一枚開いてから判断する。

「動いた」の根拠を取り違える

marker9 のとき「[drop-marker] getData is running from the DROP」が出て安心したが、それを呼んでいたのは background.js(親側)で、content script ではなかった。親が動く証拠と、ページに届いた証拠は別。 ページに届いたかは、ページの DOM に印を付けて、ページの中から読む(BiDi、下)。

Tsubaki(ops/*.tsubaki)

親プロセスの main thread では wasm が compile できない(worker の中ならできる)

Firefox は wasm の compile を eval と同じ扱いにする。親プロセスでは principal を問わず止まる (security.allow_eval_in_parent_process を立てない限り)。browser.xhtml に効く actor は親プロセス そのものなので、view と同じ thread に logic は置けない。

ChromeWorker の中なら通る。 worker の ContentSecurityPolicyAllows(runtime の dom/workers/RuntimeService.cpp)は、JS の eval だけ nsContentSecurityUtils::IsEvalAllowed に通して、 WASM は worker 自身の CSP しか見ない。ChromeWorker に CSP は無いので、compile は普通に通る。 測った(2026-09-08、実機):

どこで
親の main thread(system principal) CompileError: call to WebAssembly.compile() blocked by CSP
親の main thread + content principal の Cu.Sandbox 同じく blocked
親プロセスの ChromeWorker ok(Tsubaki の runtime を丸ごと起こして tsubakiEval まで)

なので tooling は drop の logic を ops-worker.js(ChromeWorker)に置く。view の thread も空く。 将来 runtime 側が worker の wasm も同じ check に通すようにしたら、そのときは content プロセスの ページに逃がす道がある(隠し <browser remote="true"> は content プロセスに行くことを測ってある)。

jar の中の .wasm は MIME でつまずく

jar channel は application/wasm;charset=utf-8 を返し、instantiateStreaming は厳密に application/wasm しか受けない。child は sandbox の中で instantiateStreaming を 「bytes を読んで instantiate」に差し替えている(tooling/webext-actors/build.ts)。

Tsubaki の書き味で踏むもの

webpanel を書いていて踏んだ五つ(Dict の => が無い / array literal の中の三項と range / 末尾コンマ / ; 無しの kwarg / comprehension の要素が複数行に跨げない)は runtime 0.3.0(tsubaki e90b368)で言語側が直した。 0.3.0 より前の runtime を使う drop では、まだ踏む。

copyput、それに get(d, :key, 既定)std が持つ(drops/std-tsubaki-runtime/src/ops/std.tsubaki。 runtime 0.4.1 から、drop 自身の ops より先に読まれる)。いまも無いもの: findfirstisemptySetvcat は Array 用で、数の Vector には効かない。

:type は書けない、d[:key] は教えられない

type は Tsubaki のキーワードなので :type が parse できない(Julia では書ける)。その key だけ文字のままにした。 d[:key] のほうは、indexing が dispatch を通らないので std からは教えられない — get(d, :key, 既定) を使う。

build / reproducible

actor.ts の木は build のときに一度 import される

build.ts は meta を読むために actor.ts を読み込む。だから module 直下で browser に触ると build が転ぶdata/prefs.tsdefinePrefs(...) を置いたら Services is not defined で止まった。 schema は data(定数)、そこから prefs を作るのは content hook の中(actor.ts)。

zip の時刻は 2 秒刻み、PR の merge commit は秒がずれる

CI(PR)は merge commit を checkout するので、commit の時刻が手元と数秒ずれ、xpi の bytes が変わった。 いま: 版と mtime は commit の時刻を に丸める。CI は pull_request.head.sha を checkout する。

TZtouch

手で BUILD.md をなぞったとき、touch -t がローカル時刻で解釈されて bytes が変わった。TZ=UTC を忘れない。 zip は -q -X -D(extra field 無し、dir entry 無し)、file 一覧は sort して渡す、権限は 644 / 755。

一行 sed が当たらない

生成された JS は引数が改行をまたぐことがある。前の api.js がそうだった(いまの parent/child.sys.mjs は一行)。 書き換えは正規表現で「丸ごと」当てて、当たらなかったら abort する(黙って素通りさせない)。

deno.lock--frozen

依存を lock と違う版で取ると bytes が変わる。deno install --frozen で、lock に無いものが来たら止める。 tooling を上げるときは lock も作り直して commit。

壊れた印を入れて一日溶かした

content.js に \n を文字通り書いてしまい SyntaxError。固める前に deno check --quiet で parse する。 minify(一行 400 文字超)も断る。読めない JS は drop にしない。

CI / GitHub

paths に無い変更は workflow が走らず、必須 check が「Expected — Waiting」のまま

workflow 自身と README だけ変えた PR で起きた。.github/workflows/**paths に足した。 道具(tooling / scripts)が変わった PR は drop を全部 build して確かめる。

sign は environment registry の承認待ちで止まる(わざと)

main に入っただけでは判は押されない。Actions → Review deployments → approve。 PR の中では id-token が無いので判は押せない。fork からの PR は外の人なら毎回 承認。

push の paths: drops/** だけだと、道具の変更では何も置き直されない

workflow_dispatch(name)で手で置き直す。

CI に B2 の鍵は無い

置くのは dl.f3liz.casa/drop/<uuid> への multipart POST。Worker が判と sha256 を確かめてから B2 に書く。 判が通らないものは置けないし、配られもしない(403 に理由)。巻き戻し(古い commit_time)も断る。

浅い checkout に fetch しても、合流点は降りてこない

sign job が台帳を積むとき、ledger/versionsmain を merge する。checkout が fetch-depth: 2 だと、 git fetch origin main ledger/versions をしても二つの本当の合流点はその窓の外に残るので、 git からは related なのに unrelated に見えて fatal: refusing to merge unrelated histories で止まる。 枝があるせいでも、履歴が壊れているせいでもない(git merge-base は手元ではちゃんと答える)。 sign の checkout は fetch-depth: 0。2026-09-08、std-tsubaki-runtime 0.2.0 で踏んだ (判は押されて置かれたあと、台帳を積むところだけが落ちた)。

手元で見るとき

同じ版のまま入れ直すと、.sys.mjs は古いままのことがある(いまは --dev が塞ぐ)

drop を入れ直すと content.js は新しくなる(loadSubScriptWithOptionsignoreCache)けれど、child.sys.mjs / parent.sys.mjsESM として URL で cache される。版が変わっていなければ URL も同じなので、走っているのは前の bytes のまま。

見わけかた: 入れ直したのに、drop が何も置かない。console に

Error: NetworkError when attempting to fetch resource.
resource://noraneko-drop-<uuid>-<版>/child.sys.mjs line: 25

古い .sys.mjs が、もう外した dep の版(noraneko-dep-…-0-12-0)を取りに行って いる。その URL を privileged な頁から fetch してみると、どの版が足りないのかが出る。

いまは踏まない。 scripts/build.rb --dev(= scripts/dev.rb の輪)が版に四つ目を 足すので、組み直すたびに版が動き、別名も profile の置き場も一緒に動く:

1.0.2  →  1.0.2.22138980      2026-01-01 からの秒

踏むのは、--dev を通さずに組んで、同じ版のまま入れ直したときだけ。そのときは 版を上げるか、ブラウザを建て直してから見る。dep の版を動かしたときも同じ (lib には四つ目を足していない ── dep の版は三つ組で読まれる約束なので)。

BiDi で about: の中を評価するには --remote-allow-system-access

無いと "System access is required"。noraneko の dev はこの flag 付きで起動する。 ws://127.0.0.1:5180/sessionsession.newbrowsingContext.createscript.evaluate。 about:nora:settings は system principal なので、そこから ChromeUtils.importESModule で親側を叩ける。

BiDi の session は一つしか持てない

script が途中で死ぬと session が残って "Maximum number of active sessions"。終わるときは session.end。 残ったら browser を立て直すしかない。

vite が残る

親(feles-build)が SIGKILL で落ちると vite が孤児になり、次の起動で port を塞ぐ(5174 を取られた vite が 5180 に逃げて、 browser の BiDi port を塞いだことがある)。いまは起動時に「npm:vite で cwd がこの repo」の process を片づける。

「見えない」の切り分け

  1. 親は動いているか(chrome から parent.<method>() を呼ぶ、数を数える)
  2. ページに届いているか(documentElement に属性を付けて、ページの中から読む)
  3. 届いていないなら matches / remoteTypes / event(actor.json)を疑う。content script なら about: の罠。

insertBefore は、同じ位置へでも「取り出して、入れ直す」

DOM の insertBefore は移動ではない。node を外してから入れる。ふつうの <label> なら 何も失われないが、chrome の窓が持っているものは、外された時点で終わる:

<browser>   ページが壊されて読み直される(browsingContext が別のものになる)
<input>     焦点と caret が飛ぶ
<video>     再生が最初から

実機で確かめた: 同じ位置への no-op な insertBefore でも <browser> の browsingContext は作り直された。moveBefore(DOM の、状態を保つ移動)なら、並び替えても 親を跨いでも同じまま。<browser>connectedMoveCallback があるのはこのため。

preact の挿入は diff/children.jsinsert() 一か所だけなので、std-preact-xul 1.1.0 は mount() が置いたものに、moveBefore を先に試す insertBefore を着せている (繋がっていない node には HierarchyRequestError が飛ぶので、そこで元の道に落ちる)。 view の要素に key を書くこと。key が無いと preact は移動ではなく作り直しを選ぶ。

見るには: Firefox を --marionette --remote-allow-system-access で起こして、chrome context で el.browsingContext.id を並び替えの前後で比べる(--headless でよい)。