feat(dictionary): add Hachidori backend support

- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import "../extension/reader-options.js";
import {
ANKI_TEMPLATE_MARKERS,
ankiPresetCoreMapped,
ankiTemplateMarkerNames,
applyAnkiPreset,
} from "../extension/anki-templates.js";
export const contracts = JSON.parse(readFileSync(
new URL("../test/data/anki-note-types/contracts.json", import.meta.url), "utf8",
));
const availableMarkers = new Set(ANKI_TEMPLATE_MARKERS);
const baseConfig = () => globalThis.HDReaderOptions.normaliseOptions({}).anki;
export function checkModel(contract, model, mapper = applyAnkiPreset) {
assert.ok(contract.modelNames.includes(model.name), `${contract.id}: unreviewed model name ${model.name}`);
assert.equal(new Set(model.fields).size, model.fields.length, `${contract.id}: duplicate field`);
assert.deepEqual(model.fields, Object.keys(contract.expected),
`${contract.id}: upstream fields or their order changed; review additions, removals, renames and intentional blanks`);
const mapped = mapper(baseConfig(), model.fields, contract.family);
assert.deepEqual(Object.keys(mapped.fieldTemplates), model.fields, `${contract.id}: missing or reordered output fields`);
assert.equal(mapped.fieldTemplates[model.fields[0]].value, "{expression}",
`${contract.id}: first field is not the word identifier`);
assert.equal(ankiPresetCoreMapped(mapped.fieldTemplates, contract.family), true,
`${contract.id}: the package core no longer qualifies for automatic setup`);
for (const [name, expected] of Object.entries(contract.expected)) {
const template = mapped.fieldTemplates[name];
for (const marker of ankiTemplateMarkerNames(template.value)) {
assert.ok(availableMarkers.has(marker), `${contract.id}.${name}: unsupported marker ${marker}`);
}
assert.deepEqual(template, { value: expected, overwriteMode: "coalesce" },
`${contract.id}.${name}: incorrect field mapping or overwrite mode`);
}
return mapped.fieldTemplates;
}
export function checkReport(report) {
assert.equal(report.results.length, contracts.length, "Missing or extra upstream results");
assert.equal(new Set(report.results.map(({ id }) => id)).size, contracts.length, "Duplicate result IDs");
const failures = [];
for (const contract of contracts) {
try {
const result = report.results.find(({ id }) => id === contract.id);
assert.ok(result, `${contract.id}: result is missing`);
assert.equal(result.status, "downloaded", `${contract.id}: ${result.error ?? "package was not downloaded"}`);
assert.ok(Array.isArray(result.models), `${contract.id}: model list is missing`);
const models = result.models.filter(({ name }) => contract.modelNames.includes(name));
assert.equal(models.length, 1,
`${contract.id}: expected exactly one matching note type; found ${result.models.map(({ name }) => name).join(", ")}`);
checkModel(contract, models[0]);
console.log(`PASS ${contract.id}: ${result.revision}, ${models[0].fields.length} fields, sha256:${result.sha256}`);
} catch (error) {
failures.push(error);
console.error(String(error));
}
}
if (failures.length > 0) throw new AggregateError(failures, `${failures.length} upstream mapping contracts failed`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
assert.equal(process.argv.length, 3,
"Usage: node scripts/anki-note-type-compatibility.mjs <download-report.json>");
checkReport(JSON.parse(readFileSync(process.argv[2], "utf8")));
}
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Read field schemas from fixed upstream APKGs without importing or rendering them.
Adapted from Manabitan's GPL-3.0-or-later dev/anki-note-type-upstream.py at
commit 81b149f44426dbfa8bca6af57f3bef9a3af02620.
"""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import os
from pathlib import Path
import sqlite3
import sys
import tempfile
import time
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
import zipfile
ROOT = Path(__file__).resolve().parents[1]
LIMIT = 128 * 1024 * 1024
HOSTS = {
"api.github.com",
"github.com",
"raw.githubusercontent.com",
"release-assets.githubusercontent.com",
"objects.githubusercontent.com",
}
class SafeRedirect(HTTPRedirectHandler):
"""Permit only the fixed GitHub hosts and never forward API credentials."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
validate_url(newurl)
redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
if redirected is not None and urlsplit(newurl).netloc != urlsplit(req.full_url).netloc:
redirected.remove_header("Authorization")
return redirected
def validate_url(url: str) -> None:
parts = urlsplit(url)
if (
parts.scheme != "https"
or parts.hostname not in HOSTS
or parts.username
or parts.password
or parts.port not in (None, 443)
):
raise ValueError(f"Unexpected upstream URL: {url}")
def read_bounded(stream, limit: int = LIMIT) -> bytes:
data = stream.read(limit + 1)
if len(data) > limit:
raise ValueError(f"Input exceeds {limit} bytes")
return data
def download(url: str, limit: int = LIMIT) -> bytes:
validate_url(url)
headers = {"User-Agent": "Hachidori-Anki-Compatibility", "Accept": "application/octet-stream"}
if urlsplit(url).hostname == "api.github.com":
headers["Accept"] = "application/vnd.github+json"
token = os.environ.get("GITHUB_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
for attempt in range(3):
try:
with build_opener(SafeRedirect()).open(Request(url, headers=headers), timeout=45) as response:
return read_bounded(response, limit)
except (HTTPError, URLError, TimeoutError) as error:
retryable = not isinstance(error, HTTPError) or error.code in (429, 500, 502, 503, 504)
if not retryable or attempt == 2:
raise
time.sleep(2**attempt)
raise AssertionError("Unreachable")
def api(path: str):
return json.loads(download(f"https://api.github.com/{path}", 4 * 1024 * 1024))
def resolve_source(source: dict, mode: str) -> dict:
if source["kind"] != "release":
raise ValueError(f"Unknown source kind: {source['kind']}")
endpoint = "latest" if mode == "latest" else f"tags/{quote(source['revision'], safe='')}"
release = api(f"repos/{source['repository']}/releases/{endpoint}")
if release["draft"] or release["prerelease"]:
raise ValueError("Expected a published stable release")
assets = [asset for asset in release["assets"] if asset["name"].lower().endswith(".apkg")]
if mode == "pinned":
assets = [asset for asset in assets if asset["name"] == source["asset"]]
if len(assets) != 1:
raise ValueError(f"Expected one APKG, found {[asset['name'] for asset in assets]}")
asset = assets[0]
if asset["size"] > LIMIT:
raise ValueError("APKG exceeds download limit")
return {
"revision": release["tag_name"],
"publishedAt": release["published_at"],
"asset": asset["name"],
"url": asset["browser_download_url"],
"digest": asset.get("digest"),
"pinnedSha256": source.get("sha256") if mode == "pinned" else None,
}
def verify_package(data: bytes, resolved: dict) -> str:
digest = hashlib.sha256(data).hexdigest()
for expected in (resolved.get("pinnedSha256"), (resolved.get("digest") or "").removeprefix("sha256:")):
if expected and digest != expected:
raise ValueError(f"APKG SHA-256 mismatch: expected {expected}, received {digest}")
return digest
def collection_member(archive: zipfile.ZipFile) -> str:
names = archive.namelist()
modern = [name for name in ("collection.anki21b", "collection.anki21") if name in names]
if len(modern) > 1:
raise ValueError("Ambiguous modern collection members")
member = modern[0] if modern else ("collection.anki2" if "collection.anki2" in names else None)
if member is None or names.count(member) != 1:
raise ValueError("Missing or duplicate collection member")
return member
def extract_models(data: bytes) -> list[dict]:
if len(data) > LIMIT:
raise ValueError("APKG exceeds size limit")
with zipfile.ZipFile(io.BytesIO(data)) as archive:
if len(archive.infolist()) > 10000:
raise ValueError("Too many archive members")
# Modern exports include a dummy anki2 database. Prefer the real modern
# collection and never fall back to the dummy when that collection fails.
member = collection_member(archive)
if archive.getinfo(member).file_size > LIMIT:
raise ValueError("Collection exceeds size limit")
with archive.open(member) as stream:
collection = read_bounded(stream)
if member == "collection.anki21b":
import zstandard
with zstandard.ZstdDecompressor().stream_reader(io.BytesIO(collection)) as stream:
collection = read_bounded(stream)
if not collection.startswith(b"SQLite format 3\0"):
raise ValueError("Collection is not SQLite")
with tempfile.TemporaryDirectory(prefix="hachidori-anki-") as folder:
path = Path(folder) / "collection.sqlite"
path.write_bytes(collection)
database = sqlite3.connect(path.as_uri() + "?mode=ro&immutable=1", uri=True)
try:
database.execute("PRAGMA trusted_schema=OFF")
database.execute("PRAGMA query_only=ON")
deadline = time.monotonic() + 10
database.set_progress_handler(lambda: int(time.monotonic() > deadline), 1000)
tables = {row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if {"notetypes", "fields"} <= tables:
models = [
{
"name": name,
"fields": [
field[0]
for field in database.execute(
"SELECT name FROM fields WHERE ntid=? ORDER BY ord", (note_type_id,)
)
],
}
for note_type_id, name in database.execute("SELECT id, name FROM notetypes ORDER BY id")
]
elif "col" in tables:
row = database.execute("SELECT models FROM col").fetchone()
if row is None:
raise ValueError("Collection has no model metadata")
models = [
{
"name": model["name"],
"fields": [field["name"] for field in sorted(model["flds"], key=lambda field: field["ord"])],
}
for model in json.loads(row[0]).values()
]
else:
raise ValueError("Unsupported collection schema")
finally:
database.close()
if not models:
raise ValueError("Collection contains no note types")
for model in models:
fields = model["fields"]
if (
not isinstance(model["name"], str)
or not model["name"]
or not fields
or len(fields) > 256
or any(not isinstance(field, str) or not field for field in fields)
or len(set(fields)) != len(fields)
):
raise ValueError("Invalid note type or duplicate field names")
return models
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--mode", choices=("pinned", "latest"), default="pinned")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
contracts = json.loads((ROOT / "test/data/anki-note-types/contracts.json").read_text())
report = {"mode": args.mode, "checkedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "results": []}
failed = False
for contract in contracts:
result = {"id": contract["id"], "repository": contract["source"]["repository"]}
try:
resolved = resolve_source(contract["source"], args.mode)
result.update(resolved)
package = download(resolved["url"])
result["sha256"] = verify_package(package, resolved)
result["models"] = extract_models(package)
result["status"] = "downloaded"
except Exception as error:
failed = True
result.update(status="error", error=f"{type(error).__name__}: {error}")
report["results"].append(result)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
print(json.dumps(report, indent=2, ensure_ascii=False))
return int(failed)
if __name__ == "__main__":
sys.exit(main())
+160
View File
@@ -0,0 +1,160 @@
// Capture the real extension and original artwork in a disposable Chrome profile.
// SPDX-License-Identifier: GPL-3.0-or-later
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { homedir, tmpdir } from "node:os";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const extension = resolve(root, "extension");
const output = resolve(root, "docs/store");
const cache = process.env.XDG_CACHE_HOME || resolve(homedir(), ".cache");
const chromePath = process.env.HACHIDORI_CHROME || process.env.CHROME_BIN || "/usr/bin/chromium";
const puppeteerPath = process.env.HACHIDORI_PUPPETEER
|| resolve(cache, "hachidori-e2e/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js");
assert.ok(existsSync(chromePath), "Set HACHIDORI_CHROME to Chrome for Testing or Chromium.");
const { default: puppeteer } = await import(pathToFileURL(puppeteerPath).href);
const profile = mkdtempSync(resolve(tmpdir(), "hachidori-store-assets-"));
const source = "# Original examples for store screenshots\n食べる, たべる, to eat\\n朝ごはんを食べる。 — I eat breakfast.\n光, ひかり, light; sunlight\n鳥, とり, bird\n";
const readerOptions = { popupTheme: "light", popupHeightPx: 320, popupOpacityPercent: 100 };
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(readFileSync(resolve(output, "reading-sample.html")));
});
await new Promise(done => server.listen(0, "127.0.0.1", done));
const origin = `http://127.0.0.1:${server.address().port}`;
let browser;
const blocked = [];
const interception = new Map();
async function isolate(target) {
if (!["page", "service_worker", "background_page"].includes(target.type())
&& !target.url().endsWith("/offscreen.html")) return;
if (!interception.has(target)) interception.set(target, (async () => {
const cdp = await target.createCDPSession();
cdp.on("Fetch.requestPaused", async event => {
const url = event.request.url;
if (url.startsWith(`${origin}/`)) await cdp.send("Fetch.continueRequest", { requestId: event.requestId });
else {
blocked.push(url);
await cdp.send("Fetch.failRequest", { requestId: event.requestId, errorReason: "BlockedByClient" });
}
});
await cdp.send("Fetch.enable", { patterns: [{ urlPattern: "http://*" }, { urlPattern: "https://*" }] });
})());
await interception.get(target);
}
async function until(read, predicate, description) {
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
const value = await read();
if (predicate(value)) return value;
await new Promise(done => setTimeout(done, 100));
}
throw new Error(`Timed out waiting for ${description}`);
}
async function popupText(cdp) {
const { root: document } = await cdp.send("DOM.getDocument", { depth: -1, pierce: true });
const walk = node => {
const classes = (node.attributes || []).findIndex(value => value === "class");
if (classes >= 0 && node.attributes[classes + 1].split(" ").includes("gsm-hoshidicts-popup")) return node.nodeId;
for (const child of [...node.children || [], ...node.shadowRoots || []]) {
const found = walk(child);
if (found) return found;
}
return null;
};
const nodeId = walk(document);
if (!nodeId) return "";
const { object } = await cdp.send("DOM.resolveNode", { nodeId });
try {
const { result } = await cdp.send("Runtime.callFunctionOn", { objectId: object.objectId, returnByValue: true,
functionDeclaration: "function () { return this.hidden ? '' : this.textContent; }" });
return result.value || "";
} finally {
await cdp.send("Runtime.releaseObject", { objectId: object.objectId });
}
}
try {
mkdirSync(output, { recursive: true });
browser = await puppeteer.launch({ executablePath: chromePath, headless: true, userDataDir: profile,
defaultViewport: { width: 1280, height: 800, deviceScaleFactor: 1 },
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--disable-audio-output",
`--disable-extensions-except=${extension}`, `--load-extension=${extension}`] });
browser.on("targetcreated", target => { isolate(target).catch(() => {}); });
// Chrome names the offscreen target after creating it. Its Fetch domain also
// covers the dedicated engine worker's dictionary downloads.
browser.on("targetchanged", target => { isolate(target).catch(() => {}); });
await Promise.all(browser.targets().map(isolate));
const target = await browser.waitForTarget(candidate => candidate.type() === "page" && candidate.url().endsWith("/startup.html"));
await isolate(target);
const startup = await target.page();
const settingsUrl = `${target.url().slice(0, target.url().lastIndexOf("/"))}/settings.html`;
await startup.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "light" }]);
await until(() => startup.evaluate(() => document.getElementById("setup-heading")?.textContent),
value => value === "Welcome to Hachidori", "the welcome disclosure");
const offscreen = await browser.waitForTarget(candidate => candidate.url().endsWith("/offscreen.html"));
await isolate(offscreen);
assert.equal(blocked.length, 0, "The untouched welcome page should make no dictionary or Anki request.");
await startup.screenshot({ path: resolve(output, "welcome-1280x800.png") });
// Startup rebuilds its controls on storage events; resolve and click in the
// same page task so a saved element handle cannot be detached meanwhile.
await startup.evaluate(() => document.getElementById("setup-manual").click());
await until(() => startup.evaluate(() => document.getElementById("setup-finish") !== null), Boolean, "manual setup");
await startup.evaluate(() => document.getElementById("setup-finish").click());
const settings = await browser.newPage();
await isolate(settings.target());
await settings.goto(`${settingsUrl}#custom-dictionary`);
await until(() => settings.evaluate(() => document.getElementById("custom-dictionary-open")?.checkVisibility()), Boolean, "the personal dictionary editor");
await settings.click("#custom-dictionary-open");
await until(() => settings.evaluate(() => document.getElementById("custom-dictionary-status")?.textContent),
value => value === "Loaded source revision 0.", "the empty personal source");
await settings.$eval("#custom-dictionary-source", (textarea, text) => {
textarea.value = text;
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}, source);
await settings.click("#custom-dictionary-save");
await until(() => settings.evaluate(async () => (await chrome.storage.local.get("dictionaryState")).dictionaryState?.dictionaries?.[0]?.termCount),
value => value === 3, "the production dictionary import");
await settings.evaluate(async options => {
const stored = await chrome.storage.local.get("options");
const reply = await chrome.runtime.sendMessage({ target: "hoshidicts-worker", type: "hd_options_write",
requestId: "store-reader-appearance", baseRevision: stored.options.revision, options });
if (!reply?.ok) throw new Error(reply?.error || "Could not save screenshot appearance.");
}, readerOptions);
const reading = await browser.newPage();
await isolate(reading.target());
await reading.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "light" }]);
await reading.goto(`${origin}/reading-sample.html`);
const hover = await reading.$eval("#lookup-word", node => {
const rect = node.getBoundingClientRect();
return { x: rect.x + 5, y: rect.y + rect.height / 2 };
});
await reading.mouse.move(hover.x, hover.y);
const cdp = await reading.createCDPSession();
await cdp.send("DOM.enable");
await until(() => popupText(cdp), value => value.includes("to eat") && value.includes("I eat breakfast"), "the real hover lookup");
await reading.screenshot({ path: resolve(output, "lookup-1280x800.png") });
const promo = await browser.newPage();
await promo.setViewport({ width: 440, height: 280, deviceScaleFactor: 1 });
await promo.goto(pathToFileURL(resolve(output, "promo.html")).href);
await promo.screenshot({ path: resolve(output, "promo-440x280.png") });
// Event handlers cache attachment failures; never certify an unmonitored run.
await Promise.all(interception.values());
writeFileSync(resolve(output, "capture.json"), `${JSON.stringify({ chrome: await browser.version(),
viewport: { width: 1280, height: 800 }, promo: { width: 440, height: 280 },
dictionarySource: source, readerOptions, blockedRequests: blocked }, null, 2)}\n`);
console.log(`Created welcome, real lookup and promotional assets in ${output}`);
} finally {
await browser?.close();
await new Promise(done => server.close(done));
rmSync(profile, { recursive: true, force: true });
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
// Validate both manifests, supported-browser pins, and optional release tag.
// SPDX-License-Identifier: GPL-3.0-or-later
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const CHROME_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
const MANIFEST_VERSION_COMPONENT = /^(0|[1-9]\d*)$/u;
const MAX_MANIFEST_VERSION_COMPONENT = 65_535;
const FIREFOX_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/u;
const FIREFOX_BUILD = /^stable_((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*))?)$/u;
function fail(message) {
throw new Error(message);
}
export function chromeVersion(value, label) {
const match = CHROME_VERSION.exec(value);
if (match === null) fail(`${label} must be an exact four-part Chrome version`);
const components = match.slice(1).map(Number);
if (!components.every(Number.isSafeInteger)) {
fail(`${label} contains a version component that is too large`);
}
return components;
}
export function firefoxVersion(value, label) {
const match = FIREFOX_VERSION.exec(value);
if (match === null) fail(`${label} must be a Firefox version such as 153.0`);
return match.slice(1).filter(component => component !== undefined).map(Number);
}
export function firefoxBuildVersion(value, label) {
const match = FIREFOX_BUILD.exec(value ?? "");
if (match === null) fail(`${label} must be a pinned stable Firefox build such as stable_155.0.1`);
return firefoxVersion(match[1], label);
}
export function compareVersions(left, right) {
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
const difference = (left[index] ?? 0) - (right[index] ?? 0);
if (difference !== 0) return Math.sign(difference);
}
return 0;
}
// The Firefox XPI ships the same version, and the pinned test Firefox must
// satisfy the manifest's own minimum, or the smoke test proves nothing.
export function validateFirefoxContract(manifest, firefoxManifest, tooling) {
if (firefoxManifest?.manifest_version !== 2) fail("manifest.firefox.json must stay on manifest_version 2");
if (firefoxManifest.version !== manifest.version) {
fail("manifest.firefox.json version does not match manifest.json");
}
const gecko = firefoxManifest.browser_specific_settings?.gecko;
const minimum = firefoxVersion(gecko?.strict_min_version, "gecko.strict_min_version");
const current = firefoxBuildVersion(tooling?.config?.firefox, "config.firefox");
if (compareVersions(current, minimum) < 0) {
fail("the pinned Firefox test build is older than the manifest minimum");
}
return { minimumFirefox: gecko.strict_min_version, currentFirefox: tooling.config.firefox };
}
export function validateReleaseContract(manifest, tooling, tag = null, firefoxManifest = null) {
const versionComponents = String(manifest?.version ?? "").split(".");
if (versionComponents.length < 1 || versionComponents.length > 4
|| !versionComponents.every((component) =>
MANIFEST_VERSION_COMPONENT.test(component)
&& Number(component) <= MAX_MANIFEST_VERSION_COMPONENT)) {
fail("manifest.version is not a Chrome-compatible release version");
}
if (!/^(0|[1-9]\d*)$/u.test(manifest?.minimum_chrome_version ?? "")) {
fail("manifest.minimum_chrome_version must be one Chrome major");
}
const minimum = chromeVersion(tooling?.config?.minimumChrome, "config.minimumChrome");
const current = chromeVersion(tooling?.config?.chrome, "config.chrome");
if (minimum[0] !== Number(manifest.minimum_chrome_version)) {
fail("the minimum Chrome test build does not match the manifest minimum");
}
if (compareVersions(current, minimum) < 0) {
fail("the current Chrome test build is older than the minimum build");
}
const expectedTag = manifest.version;
if (tag !== null && tag !== expectedTag) {
fail(`release tag ${JSON.stringify(tag)} must be ${expectedTag}`);
}
return {
version: manifest.version,
expectedTag,
minimumChrome: tooling.config.minimumChrome,
currentChrome: tooling.config.chrome,
...(firefoxManifest === null ? {} : validateFirefoxContract(manifest, firefoxManifest, tooling)),
};
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function commandLineTag(arguments_) {
if (arguments_.length === 0) return null;
if (arguments_.length !== 2 || arguments_[0] !== "--tag" || arguments_[1] === "") {
fail("usage: node scripts/check-release.mjs [--tag <manifest.version>]");
}
return arguments_[1];
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const contract = validateReleaseContract(
readJson(resolve(ROOT, "extension/manifest.json")),
readJson(resolve(ROOT, "test/tooling/package.json")),
commandLineTag(process.argv.slice(2)),
readJson(resolve(ROOT, "extension/manifest.firefox.json")),
);
console.log(
`Hachidori ${contract.version}: Chrome ${contract.minimumChrome} minimum, `
+ `${contract.currentChrome} current; Firefox ${contract.minimumFirefox} minimum, `
+ `${contract.currentFirefox} current; tag ${contract.expectedTag}`,
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env node
// Upload a verified release package and submit it through the Chrome Web Store API.
// SPDX-License-Identifier: GPL-3.0-or-later
import { sign } from "node:crypto";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
const API_ENDPOINT = "https://chromewebstore.googleapis.com";
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
const CHROME_WEB_STORE_SCOPE = "https://www.googleapis.com/auth/chromewebstore";
const JWT_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer";
const ITEM_ID = /^[a-p]{32}$/u;
const RESOURCE_ID = /^[A-Za-z0-9._~-]+$/u;
const RELEASE_VERSION = /^(0|[1-9]\d*)(\.(0|[1-9]\d*)){0,3}$/u;
const SUCCESSFUL_SUBMISSION_STATES = new Set([
"PENDING_REVIEW",
"STAGED",
"PUBLISHED",
"PUBLISHED_TO_TESTERS",
]);
function fail(message) {
throw new Error(message);
}
function encodedJson(value) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function requireString(value, name) {
if (typeof value !== "string" || value.trim() === "") fail(`${name} is required`);
return value;
}
export function parseServiceAccount(source) {
let credentials;
try {
credentials = JSON.parse(requireString(source, "CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON"));
} catch (error) {
fail(`CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON is not valid JSON: ${error.message}`);
}
if (credentials.type !== "service_account") {
fail("CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON must contain a service account");
}
requireString(credentials.client_email, "service account client_email");
requireString(credentials.private_key, "service account private_key");
if (credentials.token_uri !== TOKEN_ENDPOINT) {
fail(`service account token_uri must be ${TOKEN_ENDPOINT}`);
}
return credentials;
}
export function createServiceAccountAssertion(credentials, nowMilliseconds = Date.now()) {
const issuedAt = Math.floor(nowMilliseconds / 1000);
const header = {
alg: "RS256",
typ: "JWT",
...(credentials.private_key_id ? { kid: credentials.private_key_id } : {}),
};
const claims = {
iss: credentials.client_email,
scope: CHROME_WEB_STORE_SCOPE,
aud: TOKEN_ENDPOINT,
iat: issuedAt,
exp: issuedAt + 3600,
};
const unsigned = `${encodedJson(header)}.${encodedJson(claims)}`;
let signature;
try {
signature = sign("RSA-SHA256", Buffer.from(unsigned), credentials.private_key).toString("base64url");
} catch (error) {
fail(`could not sign the service account assertion: ${error.message}`);
}
return `${unsigned}.${signature}`;
}
async function responseJson(response, operation) {
const text = await response.text();
let body = {};
if (text !== "") {
try {
body = JSON.parse(text);
} catch {
if (response.ok) fail(`${operation} returned non-JSON output`);
body = { error: { message: text.slice(0, 500) } };
}
}
if (!response.ok) {
const detail = body?.error?.message || body?.error_description || response.statusText || "request failed";
fail(`${operation} failed with HTTP ${response.status}: ${detail}`);
}
return body;
}
export async function requestAccessToken(credentials, {
fetchImpl = fetch,
nowMilliseconds = Date.now(),
} = {}) {
const assertion = createServiceAccountAssertion(credentials, nowMilliseconds);
const response = await fetchImpl(TOKEN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: JWT_GRANT_TYPE,
assertion,
}),
});
const body = await responseJson(response, "service account authentication");
return requireString(body.access_token, "OAuth access_token");
}
function itemName(publisherId, itemId) {
if (!RESOURCE_ID.test(requireString(publisherId, "Chrome Web Store publisher ID"))) {
fail("Chrome Web Store publisher ID contains unsupported characters");
}
if (!ITEM_ID.test(requireString(itemId, "Chrome Web Store extension ID"))) {
fail("Chrome Web Store extension ID must contain 32 letters from a through p");
}
return `publishers/${encodeURIComponent(publisherId)}/items/${encodeURIComponent(itemId)}`;
}
async function uploadPackage(fetchImpl, accessToken, name, packageBytes) {
const response = await fetchImpl(`${API_ENDPOINT}/upload/v2/${name}:upload`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/zip",
},
body: packageBytes,
});
return responseJson(response, "Chrome Web Store package upload");
}
async function fetchItemStatus(fetchImpl, accessToken, name) {
const response = await fetchImpl(`${API_ENDPOINT}/v2/${name}:fetchStatus`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return responseJson(response, "Chrome Web Store upload status");
}
async function waitForUpload(fetchImpl, accessToken, name, upload, {
maxPolls,
pollIntervalMilliseconds,
sleep,
}) {
if (upload.uploadState === "SUCCEEDED") return upload;
if (upload.uploadState !== "IN_PROGRESS") {
fail(`Chrome Web Store package upload ended in state ${upload.uploadState || "UNKNOWN"}`);
}
for (let poll = 0; poll < maxPolls; poll += 1) {
await sleep(pollIntervalMilliseconds);
const status = await fetchItemStatus(fetchImpl, accessToken, name);
const state = status.lastAsyncUploadState;
if (state === "SUCCEEDED") return upload;
if (state === undefined || state === "UPLOAD_STATE_UNSPECIFIED" || state === "IN_PROGRESS") continue;
fail(`Chrome Web Store asynchronous package upload ended in state ${state}`);
}
fail("Chrome Web Store package upload did not finish before the polling deadline");
}
async function submitForReview(fetchImpl, accessToken, name, publishType) {
const response = await fetchImpl(`${API_ENDPOINT}/v2/${name}:publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
publishType,
blockOnWarnings: true,
skipReview: false,
}),
});
const submission = await responseJson(response, "Chrome Web Store review submission");
if (!SUCCESSFUL_SUBMISSION_STATES.has(submission.state)) {
fail(`Chrome Web Store review submission ended in state ${submission.state || "UNKNOWN"}`);
}
return submission;
}
const defaultSleep = milliseconds => new Promise(resolvePromise => setTimeout(resolvePromise, milliseconds));
export async function uploadAndSubmit({
credentialsJson,
packagePath,
publisherId,
itemId,
expectedVersion,
publishType = "DEFAULT_PUBLISH",
fetchImpl = fetch,
nowMilliseconds = Date.now(),
maxPolls = 60,
pollIntervalMilliseconds = 5000,
sleep = defaultSleep,
}) {
if (!RELEASE_VERSION.test(requireString(expectedVersion, "expected Chrome extension version"))) {
fail("expected Chrome extension version is not a valid manifest version");
}
if (!["DEFAULT_PUBLISH", "STAGED_PUBLISH"].includes(publishType)) {
fail("publish type must be DEFAULT_PUBLISH or STAGED_PUBLISH");
}
const credentials = parseServiceAccount(credentialsJson);
const packageBytes = await readFile(resolve(requireString(packagePath, "Chrome Web Store package path")));
const name = itemName(publisherId, itemId);
const accessToken = await requestAccessToken(credentials, { fetchImpl, nowMilliseconds });
const upload = await uploadPackage(fetchImpl, accessToken, name, packageBytes);
await waitForUpload(fetchImpl, accessToken, name, upload, {
maxPolls,
pollIntervalMilliseconds,
sleep,
});
if (upload.crxVersion !== undefined && upload.crxVersion !== expectedVersion) {
fail(`Chrome Web Store read package version ${upload.crxVersion}; expected ${expectedVersion}`);
}
const submission = await submitForReview(fetchImpl, accessToken, name, publishType);
return { upload, submission };
}
function commandLine(arguments_) {
const options = {};
const allowed = new Set(["package", "publisher-id", "item-id", "expected-version", "publish-type"]);
for (let index = 0; index < arguments_.length; index += 2) {
const option = arguments_[index];
const value = arguments_[index + 1];
if (!option?.startsWith("--") || value === undefined) {
fail("usage: chrome-web-store.mjs --package ZIP --publisher-id ID --item-id ID --expected-version VERSION");
}
const name = option.slice(2);
if (!allowed.has(name) || options[name] !== undefined) fail(`unknown or repeated option: ${option}`);
options[name] = value;
}
return options;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const options = commandLine(process.argv.slice(2));
const result = await uploadAndSubmit({
credentialsJson: process.env.CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON,
packagePath: options.package,
publisherId: options["publisher-id"],
itemId: options["item-id"],
expectedVersion: options["expected-version"],
publishType: options["publish-type"],
});
console.log(
`Chrome Web Store accepted ${options["item-id"]} ${options["expected-version"]}; `
+ `submission state ${result.submission.state}`,
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"excludedFiles": [
"avif-sequence.js",
"capture-audio-worklet.js",
"capture-buffer.js",
"capture-content.js",
"capture-encoder-client.js",
"capture-encoder-worker.js",
"capture-frame-client.js",
"capture-frame-worker.js",
"capture-host.js",
"capture-session.js",
"capture-speech.js",
"capture-timeline.js",
"capture.css",
"capture.html",
"capture.js",
"vendor/avif-encoder.mjs",
"vendor/avif-encoder.wasm"
]
}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
// Lint the prepared Firefox extension with the pinned web-ext.
// scripts/package-store.py builds the release XPI from the same file list.
// SPDX-License-Identifier: GPL-3.0-or-later
import { readFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
DEFAULT_FIREFOX_EXTENSION,
prepareFirefoxExtension,
} from "./prepare-firefox.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const TOOLING = resolve(ROOT, "test/tooling");
async function webExtBin() {
const packagePath = resolve(TOOLING, "node_modules/web-ext/package.json");
const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
const entry = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.["web-ext"];
if (typeof entry !== "string") throw new Error("The pinned web-ext executable is unavailable. Run npm ci --prefix test/tooling.");
return resolve(dirname(packagePath), entry);
}
export async function lintFirefoxExtension(output = DEFAULT_FIREFOX_EXTENSION) {
const source = await prepareFirefoxExtension(output);
const result = spawnSync(process.execPath, [await webExtBin(), "lint", "--source-dir", source], {
cwd: ROOT,
encoding: "utf8",
stdio: "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`web-ext lint exited with status ${result.status}`);
return source;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
if (process.argv.length > 2) throw new Error("usage: node scripts/lint-firefox.mjs");
await lintFirefoxExtension();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Package the committed Chrome ZIP, Firefox XPI and matching source using only Git/Python."""
import argparse
import hashlib
import io
import json
from pathlib import Path, PurePosixPath
import subprocess
import tarfile
import urllib.request
import zipfile
ROOT = Path(__file__).resolve().parents[1]
ENGINE = "third_party/hoshidicts/"
ENGINE_LICENSES = {
"hoshidicts-LICENSE": "LICENSE",
"glaze-LICENSE": "external/glaze/LICENSE",
"zstd-LICENSE": "external/zstd/LICENSE",
"zstd-COPYING": "external/zstd/COPYING",
"unordered_dense-LICENSE": "external/unordered_dense/LICENSE",
"libdeflate-COPYING": "external/libdeflate/COPYING",
"utf8proc-LICENSE.md": "external/utf8proc/LICENSE.md",
"utfcpp-LICENSE": "external/utfcpp/LICENSE",
"xxHash-LICENSE": "external/xxHash/LICENSE",
"kanji-processor-LICENSE": "external/kanji-processor/LICENSE",
}
def git(repo, *args):
return subprocess.check_output(["git", "-C", str(repo), *args])
def json_bytes(value):
return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode()
def tar_entries(data, strip_prefix=""):
entries = {}
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
for member in archive:
if member.isdir():
continue
if not member.name.startswith(strip_prefix):
raise ValueError(f"Unexpected archive prefix: {member.name}")
name = member.name[len(strip_prefix):]
if not name or PurePosixPath(name).is_absolute() or ".." in PurePosixPath(name).parts:
raise ValueError(f"Invalid archive path: {name}")
if member.issym():
entries[name] = (member.linkname.encode(), 0o120777)
elif member.isfile():
entries[name] = (archive.extractfile(member).read(), 0o100755 if member.mode & 0o111 else 0o100644)
else:
raise ValueError(f"Unsupported source archive entry: {name}")
return entries
def git_sources(repo, revision, prefix=""):
# Read committed objects so a later working-tree edit cannot change this pair.
entries = {prefix + name: entry for name, entry in tar_entries(git(repo, "archive", revision)).items()}
revisions = {prefix.rstrip("/") or ".": revision}
for record in git(repo, "ls-tree", "-rz", revision).split(b"\0"):
if not record:
continue
metadata, raw_name = record.split(b"\t", 1)
mode, _, object_id = metadata.split()
if mode == b"160000":
name = raw_name.decode()
nested, nested_revisions = git_sources(repo / name, object_id.decode(), prefix + name + "/")
entries.update(nested)
revisions.update(nested_revisions)
return entries, revisions
def download_source(dependency, cache):
destination = cache / (dependency["sha256"] + ".tar.gz")
if not destination.exists():
print(f"Downloading {dependency['name']} {dependency['version']}", flush=True)
with urllib.request.urlopen(dependency["url"]) as response:
data = response.read()
if hashlib.sha256(data).hexdigest() != dependency["sha256"]:
raise ValueError(f"Source checksum mismatch: {dependency['name']}")
destination.write_bytes(data)
data = destination.read_bytes()
if hashlib.sha256(data).hexdigest() != dependency["sha256"]:
raise ValueError(f"Cached source checksum mismatch: {dependency['name']}")
return tar_entries(data, dependency["strip_prefix"])
def write_zip(path, entries):
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for name, (data, mode) in sorted(entries.items()):
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
info.create_system = 3
info.external_attr = mode << 16
archive.writestr(info, data, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
with zipfile.ZipFile(path) as archive:
bad_file = archive.testzip()
if bad_file:
raise ValueError(f"ZIP integrity failure: {bad_file}")
return hashlib.sha256(path.read_bytes()).hexdigest()
def firefox_upload(upload, sources):
"""The Firefox XPI is the Chrome upload minus Chrome-only capture files, with the reviewed MV2 manifest."""
excluded = json.loads(sources["scripts/firefox-package.json"][0])["excludedFiles"]
chrome_manifest = json.loads(upload["manifest.json"][0])
firefox_manifest = json.loads(upload["manifest.firefox.json"][0])
if firefox_manifest["version"] != chrome_manifest["version"]:
raise ValueError("The Firefox manifest version does not match the Chrome manifest version.")
if firefox_manifest["manifest_version"] != 2:
raise ValueError("The Firefox manifest must stay on manifest_version 2.")
missing = [name for name in excluded if name not in upload]
if missing:
raise ValueError(f"Firefox exclusions name files absent from the Chrome package: {', '.join(missing)}")
referenced = set(firefox_manifest.get("web_accessible_resources", []))
for script in firefox_manifest.get("content_scripts", []):
referenced.update(script.get("js", []))
referenced.update(script.get("css", []))
referenced.add(firefox_manifest["background"]["page"])
referenced.add(firefox_manifest["browser_action"]["default_popup"])
referenced.add(firefox_manifest["options_page"])
leaked = sorted(referenced.intersection(excluded))
if leaked:
raise ValueError(f"The Firefox manifest references excluded files: {', '.join(leaked)}")
absent = sorted(name for name in referenced if name not in upload)
if absent:
raise ValueError(f"The Firefox manifest references missing files: {', '.join(absent)}")
firefox = {name: entry for name, entry in upload.items() if name not in excluded}
firefox["manifest.json"] = firefox.pop("manifest.firefox.json")
return firefox
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output-dir", type=Path, required=True, help="Directory outside the checkout for both ZIPs and checksums")
parser.add_argument("--cache-dir", type=Path, default=Path.home() / ".cache/hachidori-store-sources", help="Reusable checksum-verified dependency source cache")
args = parser.parse_args()
if args.output_dir.resolve().is_relative_to(ROOT):
parser.error("Choose an output directory outside the checkout.")
if git(ROOT, "status", "--porcelain", "--untracked-files=normal").strip():
parser.error("Commit the intended changes first; release packages require a clean checkout and submodules.")
revision = git(ROOT, "rev-parse", "HEAD").decode().strip()
sources, revisions = git_sources(ROOT, revision)
dependencies = json.loads(sources["scripts/store-sources.json"][0])
manifest = json.loads(sources["extension/manifest.json"][0])
stem = f"hachidori-{manifest['version']}-{revision[:12]}"
args.output_dir.mkdir(parents=True, exist_ok=True)
args.cache_dir.mkdir(parents=True, exist_ok=True)
upload = {name.removeprefix("extension/"): entry for name, entry in sources.items() if name.startswith("extension/")}
upload["LICENSE"] = sources["LICENSE"]
upload["privacy.md"] = sources["docs/privacy.md"]
upload["THIRD_PARTY_NOTICES.md"] = sources["distribution/THIRD_PARTY_NOTICES.md"]
for name, path in ENGINE_LICENSES.items():
upload["licenses/" + name] = sources[ENGINE + path]
for name, entry in sources.items():
if name.startswith("distribution/licenses/"):
upload[name.removeprefix("distribution/")] = entry
for dependency in dependencies:
if dependency["name"] == "libavif" and dependency["revision"].encode() not in sources["wasm/avif/CMakeLists.txt"][0]:
raise ValueError("Pinned libavif source no longer matches the build configuration.")
entries = download_source(dependency, args.cache_dir)
prefix = "third_party/store-sources/" + dependency["name"] + "/"
sources.update({prefix + name: entry for name, entry in entries.items()})
for name in dependency["licenses"]:
upload[f"licenses/{dependency['name']}/{name}"] = entries[name]
if dependency["name"] == "zipjs" and entries["dist/zip-core-external.min.js"][0] != upload["vendor/zip.js"][0]:
raise ValueError("Pinned zip.js source no longer matches the shipped runtime.")
sources["SOURCE_REVISIONS.json"] = (json_bytes({"repositories": revisions, "dependencies": dependencies}), 0o100644)
source_name = stem + "-source.zip"
source_hash = write_zip(args.output_dir / source_name, {stem + "/" + name: entry for name, entry in sources.items()})
reference = {"version": manifest["version"], "revision": revision, "sourceArchive": source_name, "sourceSha256": source_hash}
upload["SOURCE.json"] = (json_bytes(reference), 0o100644)
upload["SOURCE.txt"] = ((
"Hachidori is licensed under GPL-3.0-or-later. See LICENSE.\n"
f"Matching source archive: {source_name}\nSHA-256: {source_hash}\n"
"The publisher distributes this source archive alongside this release.\n"
"It includes recursive submodule sources, pinned AVIF and zip.js sources,\n"
"and docs/source-build.md. The store listing provides the download location.\n"
"The same source archive matches the Chrome ZIP and the unsigned Firefox XPI\n"
"of this release; scripts/firefox-package.json lists the files Firefox omits.\n"
).encode(), 0o100644)
firefox = firefox_upload(upload, sources)
del upload["manifest.firefox.json"]
upload_name = stem + "-chrome.zip"
upload_hash = write_zip(args.output_dir / upload_name, upload)
firefox_name = stem + "-firefox-unsigned.xpi"
firefox_hash = write_zip(args.output_dir / firefox_name, firefox)
checksums = f"{upload_hash} {upload_name}\n{firefox_hash} {firefox_name}\n{source_hash} {source_name}\n"
(args.output_dir / (stem + "-SHA256SUMS.txt")).write_text(checksums)
print(checksums, end="")
print("Publish the matching source archive before uploading the Chrome ZIP; add its public location to the store listing.")
print("The Firefox XPI is unsigned: install it temporarily from about:debugging#/runtime/this-firefox.")
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Assemble the reviewed Firefox manifest with the shared extension sources.
// SPDX-License-Identifier: GPL-3.0-or-later
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const SOURCE = resolve(ROOT, "extension");
export const DEFAULT_FIREFOX_EXTENSION = resolve(ROOT, "test/tmp/firefox-extension");
// Chrome-only capture and speech-recording files; scripts/package-store.py
// reads the same list so the release XPI and the test build agree.
export const FIREFOX_EXCLUDED_FILES = Object.freeze(
JSON.parse(await readFile(resolve(ROOT, "scripts/firefox-package.json"), "utf8")).excludedFiles,
);
function outputArgument(arguments_) {
if (arguments_.length === 0) return DEFAULT_FIREFOX_EXTENSION;
if (arguments_.length !== 2 || arguments_[0] !== "--output-dir" || arguments_[1] === "") {
throw new Error("usage: node scripts/prepare-firefox.mjs [--output-dir <path>]");
}
return resolve(arguments_[1]);
}
export async function prepareFirefoxExtension(output = DEFAULT_FIREFOX_EXTENSION) {
if (output === SOURCE || SOURCE.startsWith(`${output}/`)) {
throw new Error("The Firefox output directory must not contain extension/.");
}
await rm(output, { recursive: true, force: true });
await mkdir(output, { recursive: true });
await cp(SOURCE, output, { recursive: true });
const firefoxManifest = await readFile(resolve(SOURCE, "manifest.firefox.json"), "utf8");
await writeFile(resolve(output, "manifest.json"), firefoxManifest);
await rm(resolve(output, "manifest.firefox.json"));
await Promise.all(FIREFOX_EXCLUDED_FILES.map(path => rm(resolve(output, path))));
return output;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const output = await prepareFirefoxExtension(outputArgument(process.argv.slice(2)));
console.log(output);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+29
View File
@@ -0,0 +1,29 @@
[
{
"name": "libavif",
"revision": "1aadfad932c98c069a1204261b1856f81f3bc199",
"version": "1.3.0",
"url": "https://codeload.github.com/AOMediaCodec/libavif/tar.gz/1aadfad932c98c069a1204261b1856f81f3bc199",
"sha256": "ac93983be0cc7188ecfc654ecb77f886243edb3d3827fd45cca14c50b2d813e3",
"strip_prefix": "libavif-1aadfad932c98c069a1204261b1856f81f3bc199/",
"licenses": ["LICENSE"]
},
{
"name": "libaom",
"revision": "10aece4157eb79315da205f39e19bf6ab3ee30d0",
"version": "3.12.1",
"url": "https://storage.googleapis.com/aom-releases/libaom-3.12.1.tar.gz",
"sha256": "9e9775180dec7dfd61a79e00bda3809d43891aee6b2e331ff7f26986207ea22e",
"strip_prefix": "libaom-3.12.1/",
"licenses": ["LICENSE", "PATENTS", "third_party/libyuv/LICENSE", "third_party/fastfeat/LICENSE", "third_party/vector/LICENSE"]
},
{
"name": "zipjs",
"revision": "2.11.2",
"version": "2.11.2",
"url": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.11.2.tgz",
"sha256": "82f90c0134fc02b4963187ab2b06299758c3f9e4dd678fbdacd082ea73f09d3e",
"strip_prefix": "package/",
"licenses": ["LICENSE"]
}
]
+44
View File
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from urllib.parse import quote
from urllib.request import urlopen
import json
ROOT = Path(__file__).resolve().parents[1]
REVISION = "9cf8af0f95a555918a60b8147a2f33a6a1248442"
NAMES = ["add", "document-add", "key", "document-edit", "book-search", "speaker-2", "edit", "checkmark", "error-circle", "more-horizontal", "arrow-clockwise", "arrow-sync", "subtract", "dismiss", "open", "arrow-up", "arrow-down", "arrow-right", "star", "reorder", "settings", "desktop"]
BASE = f"https://raw.githubusercontent.com/microsoft/fluentui-system-icons/{REVISION}/"
DEST = ROOT / "extension/icons/fluent"
DEST.mkdir(parents=True, exist_ok=True)
for path in DEST.glob("*.svg"):
if path.stem not in NAMES:
path.unlink()
def download(name):
title = " ".join(word.capitalize() for word in name.split("-"))
path = f"assets/{title}/SVG/ic_fluent_{name.replace('-', '_')}_20_regular.svg"
svg = urlopen(BASE + quote(path)).read().decode().strip()
(DEST / f"{name}.svg").write_text(svg + "\n")
return name, path, svg
icons = list(ThreadPoolExecutor(max_workers=8).map(download, NAMES))
ALIASES = {
"speaker-2": ['.gsm-hoshidicts-audio-button::before'],
"more-horizontal": ['.gsm-hoshidicts-audio-button[data-state="loading"]::before', '.operational-status.is-working::before'],
"error-circle": ['.gsm-hoshidicts-audio-button[data-state="error"]::before', '.operational-status.is-error::before'],
"checkmark": ['.operational-status.is-ready::before'],
"subtract": ['.operational-status:not(.is-working):not(.is-ready):not(.is-error)::before'],
"dismiss": ['.gsm-hoshidicts-popup-close::before'],
"open": ['.gloss-link-external-icon'],
}
(DEST / "LICENSE").write_bytes(urlopen(BASE + "LICENSE").read())
(DEST / "sources.json").write_text(json.dumps({"repository": "microsoft/fluentui-system-icons", "revision": REVISION, "icons": {name: path for name, path, svg in icons}}, indent=2) + "\n")
css = '/* SPDX-License-Identifier: GPL-3.0-or-later */\n'
css += ',\n'.join(['.hd-icon', '.gsm-hoshidicts-audio-button::before', '.gsm-hoshidicts-popup-close::before', '.gloss-link-external-icon', '.operational-status::before']) + ' {\n content: "";\n display: inline-block;\n flex: 0 0 auto;\n width: 20px;\n height: 20px;\n vertical-align: middle;\n background: currentColor;\n mask: var(--hd-icon) center / contain no-repeat;\n}\n.hd-icon[hidden] { display: none; }\n'
css += '@media (forced-colors: active) {\n ' + ',\n '.join(['.hd-icon', '.gsm-hoshidicts-audio-button::before', '.gsm-hoshidicts-popup-close::before', '.gloss-link-external-icon', '.operational-status::before']) + ' { forced-color-adjust: none; background: CanvasText; }\n}\n'
for name, path, svg in icons:
uri = 'url("data:image/svg+xml,' + quote(svg, safe='') + '")'
selectors = ',\n'.join([f'.hd-icon[data-icon="{name}"]'] + ALIASES.get(name, []))
css += f'{selectors} {{ --hd-icon: {uri}; }}\n'
(ROOT / "extension/icons.css").write_text(css)
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Confirm a release XPI carries the reviewed MV2 manifest and no Chrome-only file.
// SPDX-License-Identifier: GPL-3.0-or-later
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { inflateRawSync } from "node:zlib";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
const CENTRAL_DIRECTORY_ENTRY = 0x02014b50;
const LOCAL_FILE_HEADER = 0x04034b50;
function fail(message) {
throw new Error(message);
}
// Deterministic archives from package-store.py have no ZIP64 records or comments.
export function zipEntries(buffer) {
const end = buffer.length - 22;
if (end < 0 || buffer.readUInt32LE(end) !== END_OF_CENTRAL_DIRECTORY) fail("not a ZIP archive");
const count = buffer.readUInt16LE(end + 10);
let offset = buffer.readUInt32LE(end + 16);
const entries = new Map();
for (let index = 0; index < count; index += 1) {
if (buffer.readUInt32LE(offset) !== CENTRAL_DIRECTORY_ENTRY) fail("damaged central directory");
const method = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const nameLength = buffer.readUInt16LE(offset + 28);
const extraLength = buffer.readUInt16LE(offset + 30);
const commentLength = buffer.readUInt16LE(offset + 32);
const localOffset = buffer.readUInt32LE(offset + 42);
const name = buffer.toString("utf8", offset + 46, offset + 46 + nameLength);
if (buffer.readUInt32LE(localOffset) !== LOCAL_FILE_HEADER) fail(`damaged local header for ${name}`);
const dataStart = localOffset + 30 + buffer.readUInt16LE(localOffset + 26) + buffer.readUInt16LE(localOffset + 28);
const data = buffer.subarray(dataStart, dataStart + compressedSize);
entries.set(name, () => {
if (method === 8) return inflateRawSync(data);
if (method === 0) return data;
return fail(`unsupported compression for ${name}`);
});
offset += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
export function verifyFirefoxPackage(entries, { excludedFiles, chromeVersion }) {
const manifestEntry = entries.get("manifest.json") ?? fail("the XPI has no manifest.json");
const manifest = JSON.parse(manifestEntry().toString("utf8"));
if (manifest.manifest_version !== 2) fail("the XPI manifest is not manifest_version 2");
if (manifest.background?.page !== "firefox-background.html") fail("the XPI manifest does not use the Firefox background page");
if (manifest.browser_specific_settings?.gecko?.id !== "hachidori@bee-san") fail("the XPI manifest has the wrong gecko id");
if (chromeVersion !== undefined && manifest.version !== chromeVersion) {
fail(`the XPI version ${manifest.version} does not match the Chrome manifest ${chromeVersion}`);
}
if (entries.has("manifest.firefox.json")) fail("the XPI still contains manifest.firefox.json");
const leaked = excludedFiles.filter(name => entries.has(name));
if (leaked.length > 0) fail(`the XPI contains Chrome-only files: ${leaked.join(", ")}`);
for (const name of ["LICENSE", "SOURCE.json", "SOURCE.txt", "privacy.md", "THIRD_PARTY_NOTICES.md"]) {
if (!entries.has(name)) fail(`the XPI lacks ${name}`);
}
return manifest;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const [path, ...rest] = process.argv.slice(2);
if (!path || rest.length > 0) fail("usage: node scripts/verify-firefox-package.mjs <xpi>");
const [archive, packageJson, chromeManifest] = await Promise.all([
readFile(resolve(path)),
readFile(resolve(ROOT, "scripts/firefox-package.json"), "utf8"),
readFile(resolve(ROOT, "extension/manifest.json"), "utf8"),
]);
const manifest = verifyFirefoxPackage(zipEntries(archive), {
excludedFiles: JSON.parse(packageJson).excludedFiles,
chromeVersion: JSON.parse(chromeManifest).version,
});
console.log(`${path}: Firefox ${manifest.version}, manifest_version ${manifest.manifest_version}, no Chrome-only files`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}