mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user