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
+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 });
}