thunderbird-patch-review
Easy-to-use patch review interface for Thunderbird
Contribute: ~marcc/thunderbird-review-plugin@lists.sr.ht
git clone git://mccd.space/thunderbird-patch-review
| Log | Files | Refs | README | LICENSE |
commit aabd7fedef54b88917a05e15363bac261f69a664
parent 7897796e5d0450d869d899b7e272be10d3a7ccfc
Author: Pi Agent <agent@pi.local>
Date: Sat, 18 Jul 2026 17:13:56 +0200
host: replace the installed native messaging host with a WebExtension Experiment
The native host was a separate install (host/install-host.sh +
$HOME/.mozilla/.../native-messaging-hosts/*.json) gating Apply, Browse...,
and Open editor, which read like a mandatory prerequisite and made the
eventual one-file install awkward to bake in.
Fold that privileged code into the XPI itself as a WebExtension Experiment
("patchHost") declared via experiment_apis in the manifest. Remove the
- nativeMessaging permission,
- extension/modules/native.js bridge,
- host/ directory and its POSIX-sh host + installer,
- tests/host-test.sh,
and route background.js through messenger.patchHost.* (ping/apply/
pickDir/openEditor) instead of sendNativeMessage. The experiment's
schema + implementation live under extension/api/patchHost/, the only
chrome-privileged code in the add-on; everything else stays sandboxed.
Update README, patch-review.1, AGENTS.md to drop the native-host install
step, and add manifest/experiment wiring checks to tests/run.mjs so the
privileged glue is regression-tested headlessly.
Diffstat:
| M | AGENTS.md | | | 21 | +++++++++++---------- |
| M | README | | | 81 | +++++++++++++++++++++++++++++++++---------------------------------------------- |
| A | extension/api/patchHost/implementation.js | | | 198 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/api/patchHost/schema.json | | | 63 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | extension/background.js | | | 34 | +++++++++++++++++++--------------- |
| M | extension/manifest.json | | | 15 | ++++++++++++--- |
| D | extension/modules/native.js | | | 62 | -------------------------------------------------------------- |
| D | host/host-manifest.json.in | | | 7 | ------- |
| D | host/install-host.sh | | | 31 | ------------------------------- |
| D | host/review-host.sh | | | 235 | ------------------------------------------------------------------------------- |
| M | patch-review.1 | | | 83 | ++++++++++++++++++++++++++++--------------------------------------------------- |
| D | tests/host-test.sh | | | 244 | ------------------------------------------------------------------------------- |
| M | tests/run.mjs | | | 30 | ++++++++++++++++++++++++++++++ |
13 files changed, 396 insertions(+), 708 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -11,8 +11,8 @@ commits, each with a focused message describing exactly that concern.
Scope of "atomic" here:
- One logical change per commit (a fix, a feature, a refactor — not mixed).
-- The extension still loads and `tests/run.mjs` / `tests/host-test.sh` still
- pass after every commit, not only at the tip of the series.
+- The extension still loads and `tests/run.mjs` still passes after every
+ commit, not only at the tip of the series.
- The commit message subject line summarizes the single concern; the body,
when needed, explains why.
@@ -21,16 +21,17 @@ Scope of "atomic" here:
- `extension/` — the Thunderbird MailExtension (manifest v2, no build step).
- `review/review.{html,css,js}` — the review tab UI.
- `modules/` — pure modules: diff parsing, intraline highlight, reply
- formatting, review storage, series collection, sourcehut, native bridge.
- - `background.js` — background service worker driving the host and tabs.
-- `host/` — the native messaging host (`review-host.sh`, POSIX sh) and its
- install scripts.
-- `tests/` — `run.mjs` exercises the pure modules against fixtures; generate
- fixtures with `gen-fixtures.sh`; `host-test.sh` drives the host end-to-end.
+ formatting, review storage, series collection, sourcehut.
+ - `api/patchHost/` — the privileged WebExtension Experiment (schema +
+ implementation) behind Apply, Browse..., and Open editor. The only
+ chrome-privileged code in the add-on; everything else is sandboxed.
+ - `background.js` — background page driving the experiment and tabs.
+- `tests/` — `run.mjs` exercises the pure modules and the experiment's
+ manifest wiring against fixtures; generate fixtures with
+ `gen-fixtures.sh`.
## Running the tests
```
-node tests/run.mjs # pure modules
-sh tests/host-test.sh # native host, end-to-end
+node tests/run.mjs
```
\ No newline at end of file
diff --git a/README b/README
@@ -4,9 +4,9 @@ NAME
patch-review - review git patches from email inside Thunderbird
SYNOPSIS
- A Thunderbird MailExtension plus a native messaging host. Open a patch
- email, press Review, comment on hunks, send the review as a mailing-
- list reply, and apply the series to a local repository with git-am(1).
+ A Thunderbird MailExtension. Open a patch email, press Review, comment
+ on hunks, send the review as a mailing-list reply, and apply the series
+ to a local repository with git-am(1).
DESCRIPTION
patch-review brings a review interface to git-by-email workflows
@@ -28,46 +28,37 @@ DESCRIPTION
cleared and the review tab closes. The attached drop-down offers
Preview in compose instead, which leaves the reply drafts open in
compose windows. A reply that fails to send also stays open, so
- nothing is lost. Apply series hands the raw patch messages to the
- native host, which runs git am --3way in the configured repository. On
- conflict the host runs git am --abort and reports the output, leaving
- the repository untouched. Its drop-down offers Modify and apply --
- apply, then open the touched files in your editor -- and Download
- patchset -- save the raw series as one mbox file, ready for git am.
+ nothing is lost. Apply series runs git am --3way on the raw patch
+ messages in the configured repository. On conflict git am --abort is
+ run and its output reported, leaving the repository untouched. Its
+ drop-down offers Modify and apply -- apply, then open the touched files
+ in your editor -- and Download patchset -- save the raw series as one
+ mbox file, ready for git am.
INSTALLATION
- The extension is what you install from the Add-ons Manager; the
- native host is separate and optional. Detection, the review tab,
- commenting, and Send review work with the extension alone. The
- native host is needed only for Apply (git am), the Browse... repo
- chooser, and Open editor after an apply. Install both if you want
- those features.
+ Everything ships inside the extension: install it and you are done.
+ Apply (git am), the Browse... repo chooser, and Open editor are
+ implemented by a small privileged part bundled with the add-on (a
+ WebExtension Experiment under extension/api/patchHost/), which is why
+ the Add-ons Manager warns that the add-on has full access to
+ Thunderbird. Those three features need git(1) in $PATH; nothing else
+ has an external dependency.
1. Extension (temporary, for development)
In Thunderbird open Tools -> Developer Tools -> Debug Add-ons,
choose Load Temporary Add-on..., and pick
- extension/manifest.json. The add-on is dropped on restart,
- so use step 2 for a permanent install.
+ extension/manifest.json. The add-on is dropped on restart, so
+ use step 2 for a permanent install.
2. Extension (permanent)
- Zip the extension/ directory with manifest.json at the
- archive root, then install the .xpi from the Add-ons Manager
- gear menu (Install Add-on From File...). From the project
- root:
+ Zip the extension/ directory with manifest.json at the archive
+ root, then install the .xpi from the Add-ons Manager gear menu
+ (Install Add-on From File...). From the project root:
- ( cd extension && zip -qrX ../patch-review.xpi . )
+ (cd extension && zip -qrX ../patch-review.xpi .)
- The parentheses run the zip from inside extension/ so the
- archive holds manifest.json at its root, not under
- extension/.
-
- 3. Native host (optional)
- Run host/install-host.sh once to install the native messaging
- manifest into the per-user Mozilla and Thunderbird locations.
- The host itself is strict POSIX sh(1) and needs only POSIX
- utilities plus git(1). Restart Thunderbird afterward so it is
- picked up. Without it, Apply / Browse... / Open editor report
- that the host is unreachable; everything else still works.
+ The subshell runs the zip(1) from inside extension/ so the archive
+ holds manifest.json at its root, not under extension/.
CONFIGURATION
Open the extension's preferences:
@@ -87,10 +78,9 @@ CONFIGURATION
the last path a series was applied to is used; it is remembered
until changed.
- Repository paths can be chosen with the Browse button, which opens a
- native directory chooser through the host (zenity(1), kdialog(1), or
- osascript(1), whichever is found); the path field stays editable either
- way.
+ Repository paths can be chosen with the Browse button, which opens
+ Thunderbird's native directory chooser; the path field stays editable
+ either way.
SOURCEHUT INTEGRATION
When the series arrives via a sourcehut mailing list (a lists.sr.ht
@@ -120,19 +110,16 @@ FILES
The MailExtension (manifest v2, vanilla ES modules, no build
step).
- host/review-host.sh
- Native messaging host. Speaks the length-prefixed framing with
- dd(1) and od(1), decodes the base64 envelope with awk(1), and
- never parses JSON.
-
- ~/.mozilla/native-messaging-hosts/email.coquand.patchreview.json
- Installed host manifest (also under ~/.thunderbird).
+ extension/api/patchHost/
+ The privileged part (a WebExtension Experiment): runs git-am(1)
+ and the editor through Thunderbird's Subprocess API and shows
+ the directory chooser with nsIFilePicker. Everything outside
+ this directory runs as ordinary sandboxed WebExtension code.
tests/ node tests/run.mjs exercises the pure modules (subject parsing,
diff parsing, reply formatting) against fixtures generated by
- git-format-patch(1); sh tests/host-test.sh drives the host end-
- to-end, including a conflicting apply. Regenerate fixtures with
- tests/gen-fixtures.sh.
+ git-format-patch(1), and checks the experiment's manifest
+ wiring. Regenerate fixtures with tests/gen-fixtures.sh.
CAVEATS
Patches arriving as attachments, HTML-only mail, and reviewing from a
diff --git a/extension/api/patchHost/implementation.js b/extension/api/patchHost/implementation.js
@@ -0,0 +1,198 @@
+// Privileged WebExtension Experiment behind Apply series, Browse... and
+// Open editor. It ships inside the .xpi, so installing the extension is the
+// whole install — there is no separate native messaging host.
+//
+// This file runs with full chrome privileges in Thunderbird's parent
+// process; everything privileged the add-on does lives here. It sticks to
+// long-stable platform APIs — Subprocess to run git and the editor,
+// nsIFilePicker for the directory chooser, IOUtils/PathUtils for the patch
+// workspace — and the fallbacks below cover the differences between
+// Thunderbird 115 and current.
+
+"use strict";
+
+/* globals ExtensionAPI, Services, IOUtils, PathUtils, AppConstants, ChromeUtils, Cc, Ci */
+
+// Platform modules moved from .jsm to .sys.mjs across the supported
+// Thunderbird range; try the ESM spelling first.
+function importModule(name) {
+ try {
+ return ChromeUtils.importESModule(`resource://gre/modules/${name}.sys.mjs`);
+ } catch (e) {
+ return ChromeUtils.import(`resource://gre/modules/${name}.jsm`);
+ }
+}
+
+const { Subprocess } = importModule("Subprocess");
+
+/** Run a command to completion, stderr folded into stdout. */
+async function run(command, args) {
+ const proc = await Subprocess.call({
+ command,
+ arguments: args,
+ stderr: "stdout",
+ });
+ let output = "";
+ let chunk;
+ while ((chunk = await proc.stdout.readString())) {
+ output += chunk;
+ }
+ const { exitCode } = await proc.wait();
+ return { exitCode, output };
+}
+
+async function findGit() {
+ try {
+ return await Subprocess.pathSearch("git", Subprocess.getEnvironment());
+ } catch (e) {
+ // Thunderbird launched from a desktop entry or .app bundle may see a
+ // minimal PATH; try the usual install locations before giving up.
+ for (const candidate of ["/usr/local/bin/git", "/opt/homebrew/bin/git", "/usr/bin/git"]) {
+ if (await IOUtils.exists(candidate)) {
+ return candidate;
+ }
+ }
+ throw new Error("git not found in PATH");
+ }
+}
+
+async function requireRepo(git, repo) {
+ if (!repo) {
+ throw new Error("no repository path given");
+ }
+ const check = await run(git, ["-C", repo, "rev-parse", "--is-inside-work-tree"]);
+ if (check.exitCode !== 0) {
+ throw new Error(`not a git repository: ${repo}`);
+ }
+}
+
+async function ping() {
+ const git = await findGit();
+ const version = await run(git, ["--version"]);
+ return { ok: true, git, version: version.output.trim() };
+}
+
+async function apply(repo, strategy, patches) {
+ const git = await findGit();
+ await requireRepo(git, repo);
+ if (!patches.length) {
+ throw new Error("no patches in request");
+ }
+
+ const work = PathUtils.join(
+ PathUtils.tempDir,
+ `patch-review.${Date.now()}.${Math.floor(Math.random() * 1e9)}`
+ );
+ await IOUtils.makeDirectory(work, { permissions: 0o700 });
+ try {
+ const args = ["-C", repo, "am"];
+ if (strategy !== "am") {
+ args.push("--3way");
+ }
+ for (const [i, patch] of patches.entries()) {
+ const file = PathUtils.join(work, `${String(i + 1).padStart(4, "0")}.patch`);
+ await IOUtils.writeUTF8(file, patch);
+ args.push(file);
+ }
+ const am = await run(git, args);
+ if (am.exitCode === 0) {
+ return { ok: true, output: am.output };
+ }
+ const abort = await run(git, ["-C", repo, "am", "--abort"]);
+ return {
+ ok: false,
+ output:
+ am.output +
+ abort.output +
+ "(git am --abort ran; the repository is back to its previous state)\n",
+ };
+ } finally {
+ IOUtils.remove(work, { recursive: true }).catch(() => {});
+ }
+}
+
+async function pickDir(start) {
+ const win = Services.wm.getMostRecentWindow(null);
+ if (!win) {
+ throw new Error("no window to attach the directory chooser to");
+ }
+ const picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ // nsIFilePicker.init took a window before Thunderbird ~125 and takes a
+ // BrowsingContext after; whichever the running version rejects, throws.
+ try {
+ picker.init(win.browsingContext, "Choose repository", Ci.nsIFilePicker.modeGetFolder);
+ } catch (e) {
+ picker.init(win, "Choose repository", Ci.nsIFilePicker.modeGetFolder);
+ }
+ if (start) {
+ try {
+ const dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ dir.initWithPath(start);
+ if (dir.exists() && dir.isDirectory()) {
+ picker.displayDirectory = dir;
+ }
+ } catch (e) {
+ // Not a usable start path; the picker opens at its default.
+ }
+ }
+ const rv = await new Promise((resolve) => picker.open(resolve));
+ if (rv !== Ci.nsIFilePicker.returnOK || !picker.file) {
+ return { ok: true, output: "", cancelled: true };
+ }
+ return { ok: true, output: picker.file.path };
+}
+
+async function openEditor(repo, editor, files) {
+ const git = await findGit();
+ await requireRepo(git, repo);
+ const env = Subprocess.getEnvironment();
+ let command = (editor || "").trim() || (env.EDITOR || "").trim();
+ if (!command) {
+ // Nothing configured anywhere: open the repository directory instead.
+ command = { macosx: "open", win: "start" }[AppConstants.platform] || "xdg-open";
+ files = ["."];
+ }
+
+ if (AppConstants.platform === "win") {
+ const line = [command, ...files.map((f) => `"${f}"`)].join(" ");
+ await Subprocess.call({
+ command: env.ComSpec || "C:\\Windows\\System32\\cmd.exe",
+ arguments: ["/s", "/c", line],
+ workdir: repo,
+ stderr: "ignore",
+ });
+ } else {
+ // The command may carry flags ("code -n"); let sh split it and append
+ // the files. Output goes to /dev/null so a chatty editor can never
+ // fill the pipe, and the editor is not waited on.
+ await Subprocess.call({
+ command: "/bin/sh",
+ arguments: ["-c", `exec ${command} "$@" >/dev/null 2>&1`, "patch-review-edit", ...files],
+ workdir: repo,
+ stderr: "ignore",
+ });
+ }
+ return { ok: true, output: `launched: ${command}` };
+}
+
+/** Errors become {ok: false, error} so callers get one uniform shape. */
+async function guarded(fn) {
+ try {
+ return await fn();
+ } catch (e) {
+ return { ok: false, error: e.message || String(e) };
+ }
+}
+
+var patchHost = class extends ExtensionAPI {
+ getAPI() {
+ return {
+ patchHost: {
+ ping: () => guarded(ping),
+ apply: (repo, strategy, patches) => guarded(() => apply(repo, strategy, patches)),
+ pickDir: (start) => guarded(() => pickDir(start)),
+ openEditor: (repo, editor, files) => guarded(() => openEditor(repo, editor, files)),
+ },
+ };
+ }
+};
diff --git a/extension/api/patchHost/schema.json b/extension/api/patchHost/schema.json
@@ -0,0 +1,63 @@
+[
+ {
+ "namespace": "patchHost",
+ "description": "Privileged helper behind Apply series, Browse... and Open editor: runs git am, shows a native directory chooser, and launches the editor.",
+ "functions": [
+ {
+ "name": "ping",
+ "type": "function",
+ "async": true,
+ "description": "Report whether git is reachable; resolves to {ok, git, version}.",
+ "parameters": []
+ },
+ {
+ "name": "apply",
+ "type": "function",
+ "async": true,
+ "description": "Run git am on the raw patch messages in the given repository; resolves to {ok, output}.",
+ "parameters": [
+ { "name": "repo", "type": "string" },
+ {
+ "name": "strategy",
+ "type": "string",
+ "description": "\"am3\" for git am --3way (the default), \"am\" for plain git am."
+ },
+ {
+ "name": "patches",
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Raw RFC822 patch messages, in series order."
+ }
+ ]
+ },
+ {
+ "name": "pickDir",
+ "type": "function",
+ "async": true,
+ "description": "Open a native directory chooser; resolves to {ok, output} or {ok, cancelled}.",
+ "parameters": [
+ {
+ "name": "start",
+ "type": "string",
+ "description": "Directory the chooser starts in; may be empty."
+ }
+ ]
+ },
+ {
+ "name": "openEditor",
+ "type": "function",
+ "async": true,
+ "description": "Launch the editor command from the repository with the given files as arguments.",
+ "parameters": [
+ { "name": "repo", "type": "string" },
+ {
+ "name": "editor",
+ "type": "string",
+ "description": "Shell command line; empty means $EDITOR, then a platform opener on the repository."
+ },
+ { "name": "files", "type": "array", "items": { "type": "string" } }
+ ]
+ }
+ ]
+ }
+]
diff --git a/extension/background.js b/extension/background.js
@@ -1,6 +1,6 @@
// Background script: patch detection on displayed messages, opening the
// review tab, and servicing requests from it (series data, compose replies,
-// native-host apply/edit).
+// apply/edit through the patchHost experiment).
//
// MV2 background pages can't be ES modules, so shared code is pulled in
// with dynamic import().
@@ -15,7 +15,6 @@ function mods() {
modulesPromise = Promise.all([
import(messenger.runtime.getURL("modules/patch-detect.js")),
import(messenger.runtime.getURL("modules/series.js")),
- import(messenger.runtime.getURL("modules/native.js")),
import(messenger.runtime.getURL("modules/sourcehut.js")),
]).then((loaded) => Object.assign({}, ...loaded));
}
@@ -27,6 +26,19 @@ async function getConfig() {
return Object.assign({ editor: "", strategy: "am3", mappings: [] }, found.config);
}
+// Apply, the directory chooser, and the editor launch are backed by the
+// patchHost experiment bundled under api/patchHost/. It always ships with
+// the extension, but its privileged code can still fail to load on a
+// Thunderbird whose internals moved; turn that into a normal error reply.
+function host() {
+ if (!messenger.patchHost) {
+ throw new Error(
+ "the bundled patchHost helper did not load (incompatible Thunderbird version?)"
+ );
+ }
+ return messenger.patchHost;
+}
+
// ---------------------------------------------------------------------------
// Detection: the single toolbar ("Review") button lights up when the current
// selection — or the displayed message, in standalone message tabs — looks
@@ -199,20 +211,12 @@ async function handle(request) {
for (const messageId of request.messageIds) {
patches.push(await rawMessage(messageId));
}
- return m.callHost(
- "apply",
- { repo: request.repo, strategy: config.strategy || "am3" },
- patches
- );
+ return host().apply(request.repo, config.strategy || "am3", patches);
}
case "open-editor": {
const config = await getConfig();
- return m.callHost("edit", {
- repo: request.repo,
- editor: config.editor || "",
- files: (request.files || []).join("\n"),
- });
+ return host().openEditor(request.repo, config.editor || "", request.files || []);
}
case "download-series": {
@@ -241,11 +245,11 @@ async function handle(request) {
}
case "pick-repo":
- // Native directory chooser; the host reuses the repo field as start dir.
- return m.callHost("pickdir", { repo: request.start || "" });
+ // Native directory chooser; the repo field doubles as its start dir.
+ return host().pickDir(request.start || "");
case "ping-host":
- return m.callHost("ping");
+ return host().ping();
default:
throw new Error(`unknown request: ${request.type}`);
diff --git a/extension/manifest.json b/extension/manifest.json
@@ -34,7 +34,16 @@
"compose",
"compose.send",
"downloads",
- "tabs",
- "nativeMessaging"
- ]
+ "tabs"
+ ],
+ "experiment_apis": {
+ "patchHost": {
+ "schema": "api/patchHost/schema.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "paths": [["patchHost"]],
+ "script": "api/patchHost/implementation.js"
+ }
+ }
+ }
}
diff --git a/extension/modules/native.js b/extension/modules/native.js
@@ -1,62 +0,0 @@
-// Bridge to the POSIX-sh native messaging host. The host deliberately avoids
-// a JSON parser, so we send it a single field {d: <base64>} containing a
-// line-based envelope; values that may contain arbitrary characters are
-// base64-encoded again inside the envelope:
-//
-// action apply
-// repo64 <base64 of repo path>
-// editor64 <base64 of editor command>
-// strategy am3
-// patch64 <base64 of one raw patch message> (repeated, in series order)
-// end
-//
-// The host replies with real JSON ({ok, output, ...}), which Thunderbird
-// parses for us.
-
-export const HOST_NAME = "email.coquand.patchreview";
-
-/** base64 of the UTF-8 encoding of s, without line breaks. */
-export function b64(s) {
- const bytes = new TextEncoder().encode(s);
- let bin = "";
- const CHUNK = 0x8000;
- for (let i = 0; i < bytes.length; i += CHUNK) {
- bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
- }
- return btoa(bin);
-}
-
-export function buildEnvelope(action, fields = {}, patches = []) {
- const lines = [`action ${action}`];
- for (const [name, value] of Object.entries(fields)) {
- if (value !== undefined && value !== null && value !== "") {
- lines.push(`${name}64 ${b64(String(value))}`);
- }
- }
- for (const patch of patches) {
- lines.push(`patch64 ${b64(patch)}`);
- }
- lines.push("end");
- return lines.join("\n");
-}
-
-/**
- * One-shot request to the host.
- * @returns {Promise<{ok: boolean, output?: string, error?: string}>}
- */
-export async function callHost(action, fields = {}, patches = []) {
- const envelope = buildEnvelope(action, fields, patches);
- try {
- const response = await messenger.runtime.sendNativeMessage(HOST_NAME, { d: b64(envelope) });
- return response && typeof response === "object"
- ? response
- : { ok: false, error: "malformed host response" };
- } catch (e) {
- return {
- ok: false,
- error:
- `Could not reach the native host "${HOST_NAME}". ` +
- `Run host/install-host.sh and restart Thunderbird. (${e.message})`,
- };
- }
-}
diff --git a/host/host-manifest.json.in b/host/host-manifest.json.in
@@ -1,7 +0,0 @@
-{
- "name": "email.coquand.patchreview",
- "description": "Patch Review native host: applies patch series with git am and opens the editor",
- "path": "@HOST_PATH@",
- "type": "stdio",
- "allowed_extensions": ["patchreview@coquand.email"]
-}
diff --git a/host/install-host.sh b/host/install-host.sh
@@ -1,31 +0,0 @@
-#!/bin/sh
-# Install the native messaging host manifest so Thunderbird can find
-# review-host.sh. POSIX sh only.
-set -eu
-
-dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
-host="$dir/review-host.sh"
-name="email.coquand.patchreview"
-
-chmod +x "$host"
-
-install_manifest() {
- mkdir -p "$1"
- sed "s|@HOST_PATH@|$host|" "$dir/host-manifest.json.in" > "$1/$name.json"
- echo "installed $1/$name.json"
-}
-
-# Thunderbird's native messaging search path differs across versions and
-# packagings; installing the (tiny) manifest in both candidates is harmless.
-case $(uname) in
- Darwin)
- install_manifest "$HOME/Library/Mozilla/NativeMessagingHosts"
- install_manifest "$HOME/Library/Application Support/Mozilla/NativeMessagingHosts"
- ;;
- *)
- install_manifest "$HOME/.mozilla/native-messaging-hosts"
- install_manifest "$HOME/.thunderbird/native-messaging-hosts"
- ;;
-esac
-
-echo "Restart Thunderbird for the host to be picked up."
diff --git a/host/review-host.sh b/host/review-host.sh
@@ -1,235 +0,0 @@
-#!/bin/sh
-# Native messaging host for the Patch Review Thunderbird extension.
-#
-# Strict POSIX sh and POSIX utilities only (dd, od, awk, sed, tr, wc), plus
-# git. No jq: the extension sends {"d":"<base64>"} where the payload is a
-# line-based envelope (see extension/modules/native.js), so JSON never needs
-# to be parsed here — only produced, which printf and awk can do safely.
-#
-# Handles exactly one request per invocation, which is how
-# runtime.sendNativeMessage() drives a host.
-#
-# Intentional idioms shellcheck dislikes: single-quoted awk programs (SC2016),
-# two-stage printf for binary framing (SC2059), unquoted expansions used for
-# word splitting (SC2046/SC2086), trap-invoked cleanup (SC2329).
-# shellcheck disable=SC2016,SC2059,SC2046,SC2086,SC2329
-
-set -u
-LC_ALL=C
-export LC_ALL
-
-VERSION="0.1.0"
-
-# Native messaging protocol: stdout must carry nothing but framed responses.
-# Reserve fd 3 for responses and point stdout at stderr for everything else.
-exec 3>&1 1>&2
-
-# --- workspace --------------------------------------------------------------
-
-WORK=""
-cleanup() { [ -n "$WORK" ] && rm -rf "$WORK"; }
-trap cleanup EXIT INT TERM
-
-i=0
-while :; do
- WORK="${TMPDIR:-/tmp}/patch-review.$$.$i"
- if mkdir -m 700 "$WORK" 2>/dev/null; then
- break
- fi
- i=$((i + 1))
- if [ "$i" -gt 100 ]; then
- echo "patch-review host: cannot create workspace" >&2
- exit 1
- fi
-done
-
-# --- awk helpers ------------------------------------------------------------
-
-AWK_B64DECODE='
-BEGIN {
- chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
- for (i = 1; i <= 64; i++) val[substr(chars, i, 1)] = i - 1
-}
-{
- line = $0
- gsub(/=+$/, "", line)
- n = length(line)
- for (i = 1; i <= n; i += 4) {
- chunk = substr(line, i, 4)
- len = length(chunk)
- acc = val[substr(chunk, 1, 1)] * 262144 + val[substr(chunk, 2, 1)] * 4096
- if (len >= 3) acc += val[substr(chunk, 3, 1)] * 64
- if (len >= 4) acc += val[substr(chunk, 4, 1)]
- printf "%c", int(acc / 65536) % 256
- if (len >= 3) printf "%c", int(acc / 256) % 256
- if (len >= 4) printf "%c", acc % 256
- }
-}'
-
-AWK_JSON_ESCAPE='
-BEGIN { first = 1 }
-{
- gsub(/\\/, "\\\\")
- gsub(/"/, "\\\"")
- gsub("\t", "\\t")
- if (!first) printf "\\n"
- printf "%s", $0
- first = 0
-}'
-
-b64decode() { awk "$AWK_B64DECODE"; }
-
-json_escape() {
- # Drop control characters except tab and newline, then escape for JSON.
- tr -d '\000-\010\013-\037' | awk "$AWK_JSON_ESCAPE"
-}
-
-# --- framing ----------------------------------------------------------------
-
-respond() {
- n=${#1}
- # Inner printf renders the four little-endian length bytes as octal
- # escape text; the outer printf turns them into real bytes (NUL included,
- # which never survives command substitution, hence the two stages).
- printf "$(printf '\\%03o\\%03o\\%03o\\%03o' \
- $((n % 256)) $((n / 256 % 256)) $((n / 65536 % 256)) $((n / 16777216 % 256)))" >&3
- printf %s "$1" >&3
-}
-
-fail() {
- respond "{\"ok\":false,\"error\":\"$(printf %s "$1" | json_escape)\"}"
- exit 0
-}
-
-# Read the 4-byte little-endian length header. bs=1 makes dd's reads exact.
-set -- $(dd bs=1 count=4 2>/dev/null | od -An -tu1)
-if [ $# -ne 4 ]; then
- exit 0 # closed without a request
-fi
-LEN=$(($1 + $2 * 256 + $3 * 65536 + $4 * 16777216))
-if [ "$LEN" -le 0 ] || [ "$LEN" -gt 67108864 ]; then
- fail "implausible message length: $LEN"
-fi
-
-# Read exactly LEN payload bytes; a pipe may deliver them in pieces.
-: > "$WORK/payload"
-got=0
-while [ "$got" -lt "$LEN" ]; do
- dd bs=$((LEN - got)) count=1 2>/dev/null >> "$WORK/payload"
- now=$(wc -c < "$WORK/payload")
- if [ "$now" -eq "$got" ]; then
- fail "unexpected EOF while reading request"
- fi
- got=$now
-done
-
-# --- unwrap the envelope ----------------------------------------------------
-
-case $(dd bs=6 count=1 if="$WORK/payload" 2>/dev/null) in
- '{"d":"') ;;
- *) fail "unrecognized request format" ;;
-esac
-
-sed 's/^{"d":"//; s/"}$//' "$WORK/payload" | b64decode > "$WORK/envelope"
-
-ACTION="" REPO="" EDITOR_CMD="" FILES="" STRATEGY="am3" PATCHES=0
-
-while IFS=' ' read -r keyword value || [ -n "$keyword" ]; do
- case $keyword in
- action) ACTION=$value ;;
- repo64) REPO=$(printf %s "$value" | b64decode) ;;
- editor64) EDITOR_CMD=$(printf %s "$value" | b64decode) ;;
- files64) FILES=$(printf %s "$value" | b64decode) ;;
- strategy64) STRATEGY=$(printf %s "$value" | b64decode) ;;
- patch64)
- PATCHES=$((PATCHES + 1))
- printf %s "$value" | b64decode > "$WORK/$(printf %04d "$PATCHES").patch"
- ;;
- end) break ;;
- esac
-done < "$WORK/envelope"
-
-# --- actions ----------------------------------------------------------------
-
-require_repo() {
- if [ -z "$REPO" ]; then
- fail "no repository path given"
- fi
- if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
- fail "not a git repository: $REPO"
- fi
-}
-
-case $ACTION in
- ping)
- respond "{\"ok\":true,\"host\":\"patch-review\",\"version\":\"$VERSION\"}"
- ;;
-
- apply)
- require_repo
- if [ "$PATCHES" -eq 0 ]; then
- fail "no patches in request"
- fi
- am_flags="--3way"
- if [ "$STRATEGY" = "am" ]; then
- am_flags=""
- fi
- if git -C "$REPO" am $am_flags "$WORK"/*.patch > "$WORK/log" 2>&1; then
- respond "{\"ok\":true,\"output\":\"$(json_escape < "$WORK/log")\"}"
- else
- git -C "$REPO" am --abort >> "$WORK/log" 2>&1
- echo "(git am --abort ran; the repository is back to its previous state)" >> "$WORK/log"
- respond "{\"ok\":false,\"output\":\"$(json_escape < "$WORK/log")\"}"
- fi
- ;;
-
- pickdir)
- start=${REPO:-${HOME:-/}}
- if command -v zenity >/dev/null 2>&1; then
- dir=$(zenity --file-selection --directory \
- --title="Choose repository" --filename="$start/" 2>/dev/null) || dir=""
- elif command -v kdialog >/dev/null 2>&1; then
- dir=$(kdialog --getexistingdirectory "$start" 2>/dev/null) || dir=""
- elif command -v osascript >/dev/null 2>&1; then
- dir=$(osascript -e \
- 'POSIX path of (choose folder with prompt "Choose repository")' \
- 2>/dev/null) || dir=""
- else
- fail "no directory chooser found (install zenity or kdialog); type the path instead"
- fi
- if [ -z "$dir" ]; then
- respond '{"ok":true,"output":"","cancelled":true}'
- else
- respond "{\"ok\":true,\"output\":\"$(printf %s "$dir" | json_escape)\"}"
- fi
- ;;
-
- edit)
- require_repo
- if [ -z "$EDITOR_CMD" ]; then
- EDITOR_CMD=${EDITOR:-}
- fi
- if [ -z "$EDITOR_CMD" ]; then
- EDITOR_CMD="xdg-open ."
- FILES=""
- fi
- # Split the newline-separated file list without globbing.
- set -f
- oldifs=$IFS
- IFS='
-'
- set -- $FILES
- IFS=$oldifs
- set +f
- # Detach: the editor must outlive this host process.
- ( cd "$REPO" && nohup sh -c "$EDITOR_CMD \"\$@\"" patch-review-edit "$@" \
- >/dev/null 2>&1 & )
- respond "{\"ok\":true,\"output\":\"launched: $(printf %s "$EDITOR_CMD" | json_escape)\"}"
- ;;
-
- *)
- fail "unknown action: $ACTION"
- ;;
-esac
-
-exit 0
diff --git a/patch-review.1 b/patch-review.1
@@ -2,7 +2,7 @@
.SH NAME
patch\-review \- review git patches from email inside Thunderbird
.SH SYNOPSIS
-A Thunderbird MailExtension plus a native messaging host.
+A Thunderbird MailExtension.
Open a patch email, press
.BR Review ,
comment on hunks, send the review as a mailing-list reply, and apply the
@@ -37,12 +37,12 @@ The attached drop-down offers
instead, which leaves the reply drafts open in compose windows.
A reply that fails to send also stays open, so nothing is lost.
.B Apply series
-hands the raw patch messages to the native host, which runs
+runs
.B git am \-\-3way
-in the configured repository.
-On conflict the host runs
+on the raw patch messages in the configured repository.
+On conflict
.B git am \-\-abort
-and reports the output, leaving the repository untouched.
+is run and its output reported, leaving the repository untouched.
Its drop-down offers
.I "Modify and apply"
\(em apply, then open the touched files in your editor \(em and
@@ -50,19 +50,23 @@ Its drop-down offers
\(em save the raw series as one mbox file, ready for
.BR "git am" .
.SH INSTALLATION
-The extension is what you install from the Add-ons Manager; the native
-host is separate and optional.
-Detection, the review tab, commenting, and
-.B "Send review"
-work with the extension alone.
-The native host is needed only for
+Everything ships inside the extension: install it and you are done.
.B Apply
-.RB ( git am ),
+.RB ( "git am" ),
the
.I "Browse..."
repo chooser, and
-.IR "Open editor " after an apply.
-Install both if you want those features.
+.I "Open editor"
+are implemented by a small privileged part bundled with the add-on (a
+WebExtension Experiment under
+.IR extension/api/patchHost/ ),
+which is why the Add-ons Manager warns that the add-on has full access to
+Thunderbird.
+Those three features need
+.BR git (1)
+in
+.BR $PATH ;
+nothing else has an external dependency.
.TP
1. Extension (temporary, for development)
In Thunderbird open
@@ -95,24 +99,6 @@ so the archive holds
.B manifest.json
at its root, not under
.IR extension/ .
-.TP
-3. Native host (optional)
-Run
-.B host/install\-host.sh
-once to install the native messaging manifest into the per-user Mozilla
-and Thunderbird locations.
-The host itself is strict POSIX
-.BR sh (1)
-and needs only POSIX utilities plus
-.BR git (1).
-Restart Thunderbird afterward so it is picked up.
-Without it,
-.BR Apply ,
-.IR "Browse..." ,
-and
-.I "Open editor"
-report that the host is unreachable;
-everything else still works.
.SH CONFIGURATION
Open the extension's preferences:
.TP
@@ -140,12 +126,8 @@ it is remembered until changed.
.PP
Repository paths can be chosen with the
.B Browse
-button, which opens a native directory chooser through the host
-.RB ( zenity (1),
-.BR kdialog (1),
-or
-.BR osascript (1),
-whichever is found); the path field stays editable either way.
+button, which opens Thunderbird's native directory chooser; the path field
+stays editable either way.
.SH SOURCEHUT INTEGRATION
When the series arrives via a sourcehut mailing list (a
.B lists.sr.ht
@@ -178,27 +160,20 @@ carrying just the header.
.I extension/
The MailExtension (manifest v2, vanilla ES modules, no build step).
.TP
-.I host/review\-host.sh
-Native messaging host.
-Speaks the length-prefixed framing with
-.BR dd (1)
-and
-.BR od (1),
-decodes the base64 envelope with
-.BR awk (1),
-and never parses JSON.
-.TP
-.I ~/.mozilla/native\-messaging\-hosts/email.coquand.patchreview.json
-Installed host manifest (also under
-.IR ~/.thunderbird ).
+.I extension/api/patchHost/
+The privileged part (a WebExtension Experiment): runs
+.BR git\-am (1)
+and the editor through Thunderbird's Subprocess API and shows the
+directory chooser with nsIFilePicker.
+Everything outside this directory runs as ordinary sandboxed WebExtension
+code.
.TP
.I tests/
.B node tests/run.mjs
exercises the pure modules (subject parsing, diff parsing, reply
formatting) against fixtures generated by
-.BR git\-format\-patch (1);
-.B sh tests/host\-test.sh
-drives the host end-to-end, including a conflicting apply.
+.BR git\-format\-patch (1),
+and checks the experiment's manifest wiring.
Regenerate fixtures with
.BR tests/gen\-fixtures.sh .
.SH CAVEATS
diff --git a/tests/host-test.sh b/tests/host-test.sh
@@ -1,244 +0,0 @@
-#!/bin/sh
-# End-to-end tests for host/review-host.sh, speaking the real native
-# messaging framing. POSIX sh + POSIX utilities only:
-# sh tests/host-test.sh
-#
-# Intentional idioms shellcheck dislikes: single-quoted awk programs, the
-# binary framing printf, od word splitting, and $? of test conditions.
-# shellcheck disable=SC2016,SC2059,SC2046,SC2319
-set -u
-LC_ALL=C
-export LC_ALL
-
-dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
-host="$dir/../host/review-host.sh"
-work="${TMPDIR:-/tmp}/patch-review-hosttest.$$"
-trap 'rm -rf "$work"' EXIT INT TERM
-mkdir -p "$work"
-
-failures=0
-check() { # $1 name, $2 = 0/1 pass
- if [ "$2" -eq 0 ]; then
- echo "ok $1"
- else
- failures=$((failures + 1))
- echo "FAIL $1" >&2
- fi
-}
-
-# --- base64 encoder (single line, no wrapping) ------------------------------
-
-AWK_B64ENCODE='
-BEGIN {
- chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
- for (i = 0; i < 256; i++) ord[sprintf("%c", i)] = i
- nbuf = 0
-}
-function put(b) {
- buf[nbuf++] = b
- if (nbuf == 3) flush3()
-}
-function flush3() {
- acc = buf[0] * 65536 + buf[1] * 256 + buf[2]
- printf "%s%s%s%s", substr(chars, int(acc / 262144) + 1, 1), \
- substr(chars, int(acc / 4096) % 64 + 1, 1), \
- substr(chars, int(acc / 64) % 64 + 1, 1), \
- substr(chars, acc % 64 + 1, 1)
- nbuf = 0
-}
-{
- n = length($0)
- for (i = 1; i <= n; i++) put(ord[substr($0, i, 1)])
- put(10) # the newline awk stripped
-}
-END {
- if (nbuf == 1) {
- acc = buf[0] * 65536
- printf "%s%s==", substr(chars, int(acc / 262144) + 1, 1), \
- substr(chars, int(acc / 4096) % 64 + 1, 1)
- } else if (nbuf == 2) {
- acc = buf[0] * 65536 + buf[1] * 256
- printf "%s%s%s=", substr(chars, int(acc / 262144) + 1, 1), \
- substr(chars, int(acc / 4096) % 64 + 1, 1), \
- substr(chars, int(acc / 64) % 64 + 1, 1)
- }
-}'
-
-b64encode() { awk "$AWK_B64ENCODE"; }
-
-# --- framing ----------------------------------------------------------------
-
-send_request() { # $1 = envelope file; framed request on stdout
- json="{\"d\":\"$(b64encode < "$1")\"}"
- n=${#json}
- printf "$(printf '\\%03o\\%03o\\%03o\\%03o' \
- $((n % 256)) $((n / 256 % 256)) $((n / 65536 % 256)) $((n / 16777216 % 256)))"
- printf %s "$json"
-}
-
-run_host() { # $1 = envelope file, $2 = response output file; honors EXTRA_PATH
- env_file=$1
- resp_file=$2
- send_request "$env_file" | PATH="${EXTRA_PATH:+$EXTRA_PATH:}$PATH" sh "$host" \
- > "$resp_file.raw" 2> "$resp_file.err"
- # set -- consumes the function arguments, hence the copies above.
- set -- $(od -An -tu1 -N4 "$resp_file.raw")
- if [ $# -ne 4 ]; then
- echo "(no framed response)" > "$resp_file"
- return 1
- fi
- expected=$(($1 + $2 * 256 + $3 * 65536 + $4 * 16777216))
- dd if="$resp_file.raw" ibs=1 skip=4 2>/dev/null > "$resp_file"
- actual=$(wc -c < "$resp_file")
- [ "$actual" -eq "$expected" ]
-}
-
-# --- scratch repos ----------------------------------------------------------
-
-setup_repo() {
- git -C "$1" config user.email t@example.org
- git -C "$1" config user.name Tester
- git -C "$1" config commit.gpgsign false
-}
-
-upstream="$work/upstream"
-git init -q "$upstream"
-setup_repo "$upstream"
-echo "one" > "$upstream/file.txt"
-git -C "$upstream" add file.txt
-git -C "$upstream" commit -q -m "initial"
-
-contributor="$work/contributor"
-git clone -q "$upstream" "$contributor"
-setup_repo "$contributor"
-echo "two" >> "$contributor/file.txt"
-git -C "$contributor" add file.txt
-git -C "$contributor" commit -q -m "add second line"
-git -C "$contributor" format-patch -q -1 -o "$work/patches"
-patch_file=$(ls "$work/patches"/0001-*)
-
-# --- tests ------------------------------------------------------------------
-
-# ping
-printf 'action ping\nend\n' > "$work/env-ping"
-run_host "$work/env-ping" "$work/resp-ping"
-check "host: ping framing" $?
-grep -q '"ok":true' "$work/resp-ping"
-check "host: ping ok" $?
-
-# apply
-{
- printf 'action apply\n'
- printf 'repo64 %s\n' "$(printf %s "$upstream" | b64encode)"
- printf 'strategy64 %s\n' "$(printf %s am3 | b64encode)"
- printf 'patch64 %s\n' "$(b64encode < "$patch_file")"
- printf 'end\n'
-} > "$work/env-apply"
-run_host "$work/env-apply" "$work/resp-apply"
-check "host: apply framing" $?
-grep -q '"ok":true' "$work/resp-apply"
-check "host: apply ok" $?
-[ "$(git -C "$upstream" log --format=%s -1)" = "add second line" ]
-check "host: commit landed" $?
-grep -q "two" "$upstream/file.txt"
-check "host: file content applied" $?
-
-# a conflicting patch must fail, and git am --abort must leave the repo clean
-echo "conflicting upstream change" > "$upstream/file.txt"
-git -C "$upstream" add file.txt
-git -C "$upstream" commit -q -m "upstream diverges"
-
-echo "contributor version" > "$contributor/file.txt"
-git -C "$contributor" add file.txt
-git -C "$contributor" commit -q -m "contributor change"
-git -C "$contributor" format-patch -q -1 -o "$work/patches2"
-conflict_patch=$(ls "$work/patches2"/0001-*)
-
-before=$(git -C "$upstream" rev-parse HEAD)
-{
- printf 'action apply\n'
- printf 'repo64 %s\n' "$(printf %s "$upstream" | b64encode)"
- printf 'patch64 %s\n' "$(b64encode < "$conflict_patch")"
- printf 'end\n'
-} > "$work/env-conflict"
-run_host "$work/env-conflict" "$work/resp-conflict"
-check "host: conflict framing" $?
-grep -q '"ok":false' "$work/resp-conflict"
-check "host: conflict rejected" $?
-[ "$(git -C "$upstream" rev-parse HEAD)" = "$before" ] && \
- [ -z "$(git -C "$upstream" status --porcelain)" ]
-check "host: repo clean after failed apply" $?
-
-# apply refuses a non-repo
-{
- printf 'action apply\n'
- printf 'repo64 %s\n' "$(printf %s "$work" | b64encode)"
- printf 'patch64 %s\n' "$(b64encode < "$patch_file")"
- printf 'end\n'
-} > "$work/env-norepo"
-run_host "$work/env-norepo" "$work/resp-norepo"
-grep -q '"ok":false' "$work/resp-norepo" && grep -q 'not a git repository' "$work/resp-norepo"
-check "host: rejects non-repo" $?
-
-# edit launches the configured command detached
-marker="$work/editor-ran"
-{
- printf 'action edit\n'
- printf 'repo64 %s\n' "$(printf %s "$upstream" | b64encode)"
- printf 'editor64 %s\n' "$(printf %s "touch $marker" | b64encode)"
- printf 'files64 %s\n' "$(printf %s "file.txt" | b64encode)"
- printf 'end\n'
-} > "$work/env-edit"
-run_host "$work/env-edit" "$work/resp-edit"
-check "host: edit framing" $?
-grep -q '"ok":true' "$work/resp-edit"
-check "host: edit ok" $?
-tries=0
-while [ ! -f "$marker" ] && [ "$tries" -lt 50 ]; do
- tries=$((tries + 1))
- sleep 0.1 2>/dev/null || sleep 1
-done
-[ -f "$marker" ]
-check "host: editor command ran" $?
-
-# pickdir uses the first available chooser; fake zenity so no GUI is needed
-fake="$work/fakebin"
-mkdir -p "$fake"
-cat > "$fake/zenity" <<EOF
-#!/bin/sh
-printf '%s\n' "/picked/repo path"
-EOF
-chmod +x "$fake/zenity"
-
-printf 'action pickdir\nend\n' > "$work/env-pick"
-EXTRA_PATH=$fake
-run_host "$work/env-pick" "$work/resp-pick"
-check "host: pickdir framing" $?
-grep -q '"ok":true' "$work/resp-pick" && grep -q '/picked/repo path' "$work/resp-pick"
-check "host: pickdir returns chosen path" $?
-
-# cancelled chooser (zenity exits nonzero) reports ok + cancelled, no path
-cat > "$fake/zenity" <<EOF
-#!/bin/sh
-exit 1
-EOF
-run_host "$work/env-pick" "$work/resp-pick-cancel"
-grep -q '"cancelled":true' "$work/resp-pick-cancel"
-check "host: pickdir cancel" $?
-EXTRA_PATH=""
-
-# unknown action
-printf 'action bogus\nend\n' > "$work/env-bogus"
-run_host "$work/env-bogus" "$work/resp-bogus"
-grep -q '"ok":false' "$work/resp-bogus"
-check "host: unknown action rejected" $?
-
-# --- summary ----------------------------------------------------------------
-
-if [ "$failures" -gt 0 ]; then
- echo "" >&2
- echo "$failures failure(s)" >&2
- exit 1
-fi
-echo ""
-echo "all host tests passed"
diff --git a/tests/run.mjs b/tests/run.mjs
@@ -336,6 +336,36 @@ function check(name, cond, extra = "") {
check("reply: empty without comments", formatReview(p, {}) === "");
}
+// --- patchHost experiment wiring --------------------------------------------
+// The privileged code only runs inside Thunderbird, but the manifest glue
+// and the script's syntax can be checked here.
+
+{
+ const root = join(here, "..", "extension");
+ const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8"));
+ const experiment = (manifest.experiment_apis || {}).patchHost;
+ check("experiment: declared in the manifest", Boolean(experiment));
+ check(
+ "experiment: nativeMessaging permission gone",
+ !(manifest.permissions || []).includes("nativeMessaging")
+ );
+
+ const schema = JSON.parse(readFileSync(join(root, experiment.schema), "utf8"));
+ const names = schema[0].functions.map((f) => f.name).sort().join(",");
+ check("experiment: schema functions", names === "apply,openEditor,pickDir,ping");
+ check("experiment: schema functions async", schema[0].functions.every((f) => f.async));
+
+ const source = readFileSync(join(root, experiment.parent.script), "utf8");
+ let parses = true;
+ try {
+ new Function(source);
+ } catch (e) {
+ parses = false;
+ }
+ check("experiment: implementation parses", parses);
+ check("experiment: defines the patchHost class", /\bvar patchHost\s*=/.test(source));
+}
+
// --- summary ----------------------------------------------------------------
if (failures) {