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
+36
View File
@@ -0,0 +1,36 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const source = path.join(repoRoot, 'vendor', 'hachidori');
const output = path.join(repoRoot, 'build', 'hachidori');
const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8'));
// Upstream commits the engine binaries. Verify the pinned bytes before staging
// so ordinary app builds need neither Emscripten nor network access.
for (const [file, expected] of Object.entries(provenance.artifacts)) {
const actual = createHash('sha256')
.update(fs.readFileSync(path.join(source, file)))
.digest('hex');
if (actual !== expected) throw new Error(`Hachidori artifact checksum mismatch: ${file}`);
}
const extension = path.join(source, 'extension');
const manifest = JSON.parse(fs.readFileSync(path.join(extension, 'manifest.json'), 'utf8'));
for (const file of [
manifest.options_page,
manifest.background.service_worker,
'offscreen.html',
...manifest.content_scripts.flatMap(({ js, css }) => [...js, ...css]),
]) {
if (!fs.existsSync(path.join(extension, file)))
throw new Error(`Missing Hachidori asset: ${file}`);
}
fs.rmSync(output, { recursive: true, force: true });
fs.mkdirSync(output, { recursive: true });
fs.cpSync(extension, output, { recursive: true });
for (const file of ['LICENSE', 'SOURCE.json', 'README.md']) {
fs.copyFileSync(path.join(source, file), path.join(output, file));
}
process.stdout.write(`Hachidori ${provenance.revision} staged in ${output}\n`);
+110
View File
@@ -0,0 +1,110 @@
// Exercises the real entry point with disposable Linux config and data directories.
const { app, BrowserWindow, session, shell } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
if (process.platform !== 'linux')
throw new Error('This app-entry smoke requires Linux XDG isolation.');
const root = process.cwd();
const backend = process.argv.includes('--backend=yomitan') ? 'yomitan' : 'hachidori';
const profile = fs.mkdtempSync('/tmp/subminer-hachi-settings-');
process.env.XDG_CONFIG_HOME = profile;
process.env.XDG_DATA_HOME = path.join(profile, 'data');
fs.mkdirSync(path.join(profile, 'SubMiner'));
fs.writeFileSync(
path.join(profile, 'SubMiner', 'config.json'),
JSON.stringify({
dictionaryBackend: backend,
mpv: { socketPath: path.join(profile, 'missing-mpv.sock') },
ankiConnect: { enabled: false },
startupWarmups: { lowPowerMode: true },
discordPresence: { enabled: false },
updates: { enabled: false },
}),
);
const openedLinks = [];
shell.openExternal = async (url) => {
openedLinks.push(url);
};
app.setAppPath(root);
app.getVersion = () => require(path.join(root, 'package.json')).version;
process.env.SUBMINER_APP_LOG = path.join(profile, 'app.log');
app.commandLine.appendSwitch('ozone-platform', 'x11');
app.commandLine.appendSwitch('disable-gpu');
app.commandLine.appendSwitch('disable-dev-shm-usage');
process.argv = [process.execPath, root, '--hachidori', '--log-level', 'debug'];
require(path.join(root, 'dist/main-entry.js'));
const deadline = setTimeout(() => {
console.error(
'FAIL timeout',
BrowserWindow.getAllWindows().map((w) => w.webContents.getURL()),
);
app.exit(1);
}, 60000);
(async () => {
await app.whenReady();
let window;
for (let i = 0; i < 300; i++) {
window = BrowserWindow.getAllWindows().find(
(w) => w.getTitle().includes('Hachidori') && w.isVisible(),
);
if (window) break;
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(window, 'Hachidori settings window opens from actual app flag');
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
assert.match(window.webContents.getURL(), /settings.html/);
const status = await window.webContents.executeJavaScript(
`chrome.runtime.sendMessage({target:'hoshidicts-offscreen',type:'hd_status',requestId:'app-settings-smoke'})`,
);
assert.equal(status.ok, true);
assert.equal(status.ready, true);
console.log(
'PASS actual --hachidori startup, visible settings, isolated backend session, native engine ready',
);
app.emit('second-instance', {}, [process.execPath, root, '--yomitan'], root);
let yomi;
for (let i = 0; i < 200; i++) {
yomi = BrowserWindow.getAllWindows().find(
(w) => w.getTitle().includes('Yomitan') && w.isVisible(),
);
if (yomi) break;
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(yomi, 'inactive --yomitan settings opens');
assert.equal(yomi.webContents.session, session.defaultSession);
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
app.emit('second-instance', {}, [process.execPath, root, '--toggle-visible-overlay'], root);
let overlay;
for (let i = 0; i < 200; i++) {
overlay = BrowserWindow.getAllWindows().find((w) =>
w.webContents.getURL().includes('/renderer/index.html?'),
);
if (overlay && !overlay.webContents.isLoading()) break;
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(overlay, 'actual overlay initialized');
assert.equal(
overlay.webContents.session,
backend === 'hachidori' ? session.fromPartition('persist:hachidori') : session.defaultSession,
);
const requestLink = (url) =>
overlay.webContents.executeJavaScript(`new Promise((resolve,reject)=>{
const timer=setTimeout(()=>reject(Error('No external link acknowledgment')),3000);
window.addEventListener('hachidori-open-external-result',e=>{clearTimeout(timer);resolve(e.detail);},{once:true});
window.dispatchEvent(new CustomEvent('hachidori-open-external',{detail:{requestId:'smoke-link',url:${JSON.stringify(url)}}}));
})`);
assert.equal((await requestLink('file:///tmp/private')).ok, false);
assert.equal((await requestLink('https://example.com/word')).ok, backend === 'hachidori');
assert.deepEqual(openedLinks, backend === 'hachidori' ? ['https://example.com/word'] : []);
console.log(
`PASS ${backend} overlay session, independent settings windows, real preload external link bridge`,
);
clearTimeout(deadline);
app.exit(0);
})().catch((error) => {
console.error(error);
app.exit(1);
});
+248
View File
@@ -0,0 +1,248 @@
// Run after bun run build. This uses a temporary profile and small local ZIPs.
const { app, BrowserWindow, protocol, session } = require('electron');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const root = path.resolve(__dirname, '..');
const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js'));
const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js'));
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-'));
protocol.registerSchemesAsPrivileged([
{
scheme: 'chrome-extension',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
bypassCSP: true,
},
},
]);
app.setPath('userData', profile);
app.setAppPath(root);
app.disableHardwareAcceleration();
app.on('window-all-closed', () => {});
const deadline = setTimeout(() => {
console.error('Hachidori parser verification timed out', profile);
app.exit(1);
}, 120_000);
function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') {
const zipPath = path.join(profile, name + '.zip');
writeStoredZip(zipPath, [
{
name: 'index.json',
data: Buffer.from(JSON.stringify({ title, revision: '1', format: 3, frequencyMode })),
},
{ name: bankName, data: Buffer.from(JSON.stringify(entries)) },
]);
return zipPath;
}
app
.whenReady()
.then(async () => {
const targetSession = session.fromPartition('persist:hachidori-check');
const extension = await targetSession.extensions.loadExtension(
path.join(root, 'build/hachidori'),
{ allowFileAccess: true },
);
// Keep a host window alive while the dictionary importer opens/closes its
// temporary settings windows, matching the running app's window lifecycle.
const host = new BrowserWindow({
show: false,
webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false },
});
await host.loadURL(`chrome-extension://${extension.id}/settings.html`);
let parserWindow = null;
let readyPromise = null;
let initPromise = null;
const deps = {
getYomitanExt: () => extension,
getYomitanSession: () => targetSession,
getYomitanParserWindow: () => parserWindow,
setYomitanParserWindow: (value) => {
parserWindow = value;
},
getYomitanParserReadyPromise: () => readyPromise,
setYomitanParserReadyPromise: (value) => {
readyPromise = value;
},
getYomitanParserInitPromise: () => initPromise,
setYomitanParserInitPromise: (value) => {
initPromise = value;
},
};
const errors = [];
const logger = {
error: (...args) => {
errors.push(args);
console.error(...args);
},
};
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
assert.equal(
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:18766', deps, logger, {
forceOverride: true,
deck: 'Test Mining',
}),
true,
);
// Exercise discovery and revisioned options writes in the real extension,
// without creating a note or changing the user's Anki collection.
const fields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
const metadataServer = http.createServer((request, response) => {
let body = '';
request.on('data', (chunk) => {
body += chunk;
});
request.on('end', () => {
const message = JSON.parse(body);
assert.equal(message.action, 'multi');
const result = message.params.actions.map(({ action }) => ({
result:
action === 'deckNames'
? ['Test Mining']
: action === 'modelNames'
? ['Custom Japanese']
: fields,
error: null,
}));
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify({ result, error: null }));
});
});
await new Promise((resolve) => metadataServer.listen(0, '127.0.0.1', resolve));
try {
const url = `http://127.0.0.1:${metadataServer.address().port}`;
const ankiConfig = {
tags: ['SubMiner', 'Autofill'],
fields: {
word: 'Term',
sentence: 'Context',
wordAudio: 'Pronunciation',
image: 'Image',
},
};
assert.equal(
await parser.syncYomitanDefaultAnkiServer(url, deps, logger, {
forceOverride: true,
deck: 'Test Mining',
ankiConfig,
}),
true,
);
const anki = await parserWindow.webContents.executeJavaScript(
`(async () => (await chrome.storage.local.get('options')).options.anki)()`,
);
assert.equal(anki.model, 'Custom Japanese');
assert.equal(anki.deck, 'Test Mining');
assert.deepEqual(anki.tags, ankiConfig.tags);
assert.equal(anki.fieldTemplates.Term.value, '{expression}');
assert.equal(anki.fieldTemplates.Pronunciation.value, '{audio}');
assert.equal(anki.fieldTemplates.Context.value, '{sentence}');
assert.deepEqual(anki.templates[0].fieldTemplates, anki.fieldTemplates);
console.log('Hachidori Anki auto-population passed with an empty dictionary library');
} finally {
await new Promise((resolve) => metadataServer.close(resolve));
}
const archives = [
fixture('terms', 'SubMiner Test Terms', 'term_bank_1.json', [
['食べる', 'たべる', '', 'v1', 0, ['to eat'], 1, ''],
]),
fixture('names', 'SubMiner Character Dictionary (AniList 1)', 'term_bank_1.json', [
['ミナト', 'みなと', '', 'n', 0, ['name'], 1, ''],
]),
fixture('frequency', 'SubMiner Test Frequency', 'term_meta_bank_1.json', [
['食べる', 'freq', { reading: 'たべる', frequency: 42 }],
['頻度だけ', 'freq', { reading: 'ひんどだけ', frequency: 120 }],
['頻度だけ', 'freq', { reading: 'べつのよみ', frequency: 250 }],
['頻度だけ', 'freq', 17],
]),
fixture(
'occurrences',
'SubMiner Test Occurrences',
'term_meta_bank_1.json',
[['頻度だけ', 'freq', 9000]],
'occurrence-based',
),
];
for (const archive of archives)
assert.equal(await parser.importYomitanDictionaryFromZip(archive, deps, logger), true);
const dictionaries = await parser.getYomitanDictionaryInfo(deps, logger);
assert.equal(dictionaries.length, 4);
parser.clearYomitanParserCachesForWindow(parserWindow);
const tokens = await parser.requestYomitanScanTokens('ミナト 食べた', deps, logger, {
includeNameMatchMetadata: true,
currentCharacterDictionaryMediaId: 1,
});
assert.equal(tokens[0].isNameMatch, true);
assert.equal(tokens[1].headword, '食べる');
assert.equal(tokens[1].reading, 'たべた');
assert.equal(tokens[1].startPos, 4);
assert.equal(tokens[1].endPos, 7);
assert.equal(tokens[1].frequencyRank, 42);
assert.deepEqual(tokens[1].wordClasses, ['v1']);
// Remove all term dictionaries before checking direct frequency queries.
for (const title of ['SubMiner Test Terms', 'SubMiner Character Dictionary (AniList 1)']) {
assert.equal(await parser.deleteYomitanDictionaryByTitle(title, deps, logger), true);
}
parser.clearYomitanParserCachesForWindow(parserWindow);
const exact = await parser.requestYomitanTermFrequencies(
[{ term: '頻度だけ', reading: 'ひんどだけ' }],
deps,
logger,
);
assert.deepEqual(
exact.map((value) => value.frequency).sort((a, b) => a - b),
[17, 120],
);
assert.ok(
exact.some(
(value) => value.frequency === 120 && value.hasReading && value.reading === 'ひんどだけ',
),
);
assert.ok(
exact.some((value) => value.frequency === 17 && !value.hasReading && value.reading === null),
);
const allReadings = await parser.requestYomitanTermFrequencies(
[{ term: '頻度だけ', reading: null }],
deps,
logger,
);
assert.deepEqual(
allReadings.map((value) => value.frequency).sort((a, b) => a - b),
[17, 120, 250],
);
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
assert.equal(
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger),
true,
);
const directSettings = await parser.getYomitanSettingsFull(deps, logger);
assert.equal(directSettings.profiles[0].options.anki.server, 'http://127.0.0.1:8765');
const proxyState = await host.webContents.executeJavaScript(
"chrome.storage.local.get('subminerAnkiProxyUrl')",
);
assert.equal(proxyState.subminerAnkiProxyUrl, null);
for (const entry of await parser.getYomitanDictionaryInfo(deps, logger)) {
assert.equal(await parser.deleteYomitanDictionaryByTitle(entry.title, deps, logger), true);
}
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
assert.equal(errors.length, 0);
console.log(
'PASS Hachidori native import, scanner, character names, frequency-only dictionaries, reading provenance, settings and removal',
);
clearTimeout(deadline);
app.exit(0);
})
.catch((error) => {
console.error(error);
clearTimeout(deadline);
app.exit(1);
});
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import '../vendor/hachidori/extension/reader-options.js';
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
import { createAnkiWorkerService } from '../vendor/hachidori/extension/anki-worker.js';
async function mine({ proxy = true, audioFails = false } = {}) {
const events: string[] = [];
const filename = `hachidori_${'a'.repeat(64)}.mp3`;
let fields: Record<string, string> = {};
let initialAudio = '';
let stored = false;
const gateway = createAnkiGateway({
readSubminerProxyUrl: async () => (proxy ? 'http://127.0.0.1:8766' : null),
fetch: async (_url: string, options: RequestInit) => {
const { action, params } = JSON.parse(String(options.body));
events.push(action);
let result: unknown;
switch (action) {
case 'multi':
result = [
{ result: ['Default'], error: null },
{ result: ['Basic'], error: null },
{ result: ['Expression', 'ExpressionAudio'], error: null },
];
break;
case 'canAddNotes':
result = [true];
break;
case 'canAddNotesWithErrorDetail':
result = [{ canAdd: true, error: null }];
break;
case 'getMediaFilesNames':
result = stored ? [filename] : [];
break;
case 'storeMediaFile':
stored = true;
result = filename;
break;
case 'addNote':
fields = { ...params.note.fields };
initialAudio = fields.ExpressionAudio ?? '';
if (initialAudio) assert.ok(stored, 'audio must exist before the note references it');
result = 123;
break;
case 'notesInfo':
result = [
{
noteId: 123,
fields: Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, { value }]),
),
},
];
break;
case 'updateNoteFields':
Object.assign(fields, params.note.fields);
result = null;
break;
default:
throw new Error(`Unexpected action: ${action}`);
}
return Response.json({ result, error: null });
},
});
const service = createAnkiWorkerService({
gateway,
readOptions: async () => ({
anki: {
url: 'http://127.0.0.1:8766',
apiKey: '',
templates: [
{
id: 'default',
name: 'Default',
deck: 'Default',
model: 'Basic',
tags: [],
fields: {},
duplicateScope: 'model',
duplicateBehavior: 'prevent',
captureScreenshot: false,
fieldTemplates: {
Expression: { value: '{expression}', overwriteMode: 'overwrite' },
ExpressionAudio: { value: '{audio}', overwriteMode: 'overwrite' },
},
},
],
},
audioSources: [
{ id: 'test', enabled: true, type: 'custom', url: 'https://example.test/{term}' },
],
mediaCapture: { enabled: false },
}),
readDictionaries: async () => [],
engine: async () => ({ ready: true, loading: false, generation: 1 }),
offscreen: async (message: { type: string; audio?: string }) => {
if (message.type === 'hd_anki_audio') {
events.push('pronunciation');
if (audioFails) throw new Error('No pronunciation available');
return { filename, data: 'YXVkaW8=' };
}
return { fields: { Expression: '猫', ExpressionAudio: message.audio ?? '' }, media: [] };
},
duplicateIndex: { source: async () => null, recordWrite: async () => {} },
});
const status = await service.status();
assert.equal(status.available, true, status.error);
const result = await service.submit({
configKey: status.configKey,
generation: 1,
term: { expression: '猫', reading: 'ねこ' },
});
return { result, initialAudio, fields, events, filename };
}
test('SubMiner receives pronunciation in the initial Hachidori note, before enrichment starts', async () => {
const value = await mine();
assert.equal(value.result.state, 'added');
assert.equal(value.initialAudio, `[sound:${value.filename}]`);
assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1);
});
test('direct Hachidori keeps deferred pronunciation', async () => {
const value = await mine({ proxy: false });
assert.equal(value.initialAudio, '');
assert.equal(value.fields.ExpressionAudio, `[sound:${value.filename}]`);
});
test('unavailable pronunciation remains a warning without a late audio write', async () => {
const value = await mine({ audioFails: true });
assert.equal(value.result.state, 'added');
assert.match(value.result.warnings.join(' '), /No pronunciation available/);
assert.equal(value.initialAudio, '');
assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1);
assert.equal(value.events.includes('updateNoteFields'), false);
});
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import test from 'node:test';
import { CHARACTER_DICTIONARY_TITLE_PREFIX } from '../src/core/services/tokenizer/character-dictionary-title';
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
const bridge = readFileSync(
new URL('../vendor/hachidori/extension/subminer-host.js', import.meta.url),
'utf8',
);
function run(code: string) {
runInNewContext(`${bridge}\n${code}`, {
window: new EventTarget(),
EventTarget,
CustomEvent,
assert,
characterPrefix: CHARACTER_DICTIONARY_TITLE_PREFIX,
KeyboardEvent: class {
constructor(_type: string, options: KeyboardEventInit = {}) {
Object.assign(this, options);
}
},
});
}
test('Hachidori publishes popup state independently from successful lookups', () => {
run(`
const events = [];
for (const name of ['yomitan-popup-shown', 'yomitan-popup-hidden', 'subminer-yomitan-lookup']) {
window.addEventListener(name, () => events.push(name));
}
const attributes = new Map();
const host = { setAttribute: (name, value) => attributes.set(name, value) };
SubMinerHachidori.attention(host, true);
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'true');
assert.equal(events.join(','), 'yomitan-popup-shown');
SubMinerHachidori.lookup();
SubMinerHachidori.attention(host, false);
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'false');
assert.equal(events.join(','), 'yomitan-popup-shown,subminer-yomitan-lookup,yomitan-popup-hidden');
`);
});
test('Hachidori promotes character glossaries and results without overriding linguistic rank', () => {
run(`
const result = (id, matched, dictionary, options = {}) => ({
matched, deinflected: matched, trace: [], preprocessorSteps: 0,
term: { expression: matched, reading: 'reading', glossaries: [{ dictionary }] }, ...options, id,
});
const character = characterPrefix + ' - Current show';
const longer = result('longer', '花子さん', 'General');
const normal = result('normal', '花子', 'General');
const person = result('person', '花子', character);
const shorter = result('shorter', '花', character);
const output = SubMinerHachidori.prioritizeCharacterResults([longer, normal, person, shorter]);
assert.equal(output.map(entry => entry.id).join(','), 'longer,person,normal,shorter');
const merged = result('merged', '花子', 'General');
merged.term.glossaries.push({ dictionary: character }, { dictionary: 'Second general' });
const [promoted] = SubMinerHachidori.prioritizeCharacterResults([merged]);
assert.equal(promoted.term.glossaries.map(g => g.dictionary).join(','), character + ',General,Second general');
assert.equal(merged.term.glossaries[0].dictionary, 'General');
for (const change of [{ preprocessorSteps: 1 }, { trace: [{}] }, { deinflected: '別の語' }]) {
const transformed = { ...person, ...change };
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, transformed])[0].id, 'normal');
}
const preferredReading = { ...normal, term: { ...normal.term, reading: 'preferred' } };
assert.equal(SubMinerHachidori.prioritizeCharacterResults([person, preferredReading], {primaryReading: 'preferred'})[0].id, 'normal');
const aliased = result('aliased', '花子', 'Imported character data');
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, aliased], {}, [{title: 'Imported character data', displayName: character}])[0].id, 'aliased');
`);
});
test('Hachidori sends private mining metadata only to the configured SubMiner proxy', async () => {
const requests: Array<{ url: string; body: string }> = [];
const proxy = 'http://127.0.0.1:8766';
const gateway = createAnkiGateway({
readSubminerProxyUrl: async () => proxy,
fetch: async (url: string, options: RequestInit) => {
assert.equal(typeof options.body, 'string');
requests.push({ url, body: String(options.body) });
return Response.json({ result: 123, error: null });
},
});
const params = {
note: { fields: { Expression: '花子' } },
subminerDuplicateNoteIds: [456],
subminerEnrich: true,
};
await gateway.invoke('addNote', params, '', 1000, proxy);
await gateway.invoke('addNote', params, '', 1000, 'http://127.0.0.1:8765');
assert.deepEqual(
requests.map((request) => JSON.parse(request.body).params),
[params, { note: params.note }],
);
assert.equal(params.subminerEnrich, true);
const direct = createAnkiGateway({
readSubminerProxyUrl: async () => null,
fetch: async (_url: string, options: RequestInit) => {
assert.deepEqual(JSON.parse(String(options.body)).params, { note: params.note });
return Response.json({ result: null, error: null });
},
});
await direct.invoke('updateNoteFields', params, '', 1000, proxy);
});
test('Hachidori routes host commands, validates keyboard input and disconnects', () => {
run(`
const calls = [];
const disconnect = SubMinerHachidori.connect({
hide: () => calls.push('hide'), clear: () => calls.push('clear'),
action: name => calls.push(name), cycleAudio: direction => calls.push(direction),
scroll: (x, y) => calls.push(x + ':' + y),
keydown: event => calls.push(event.key + ':' + event.ctrlKey + ':' + event.shiftKey),
});
const send = detail => window.dispatchEvent(new CustomEvent('subminer-yomitan-popup-command', {detail}));
send(null);
send({ type: 'forwardKeyDown', key: 12, modifiers: [] });
send({ type: 'mineSelected' });
send({ type: 'playCurrentAudio' });
send({ type: 'scanSelectedText' });
send({ type: 'cycleAudioSource', direction: -1 });
send({ type: 'scrollBy', deltaX: Infinity, deltaY: 40 });
send({ type: 'forwardKeyDown', key: 'j', code: 'KeyJ', modifiers: ['ctrl', 'shift'] });
send({ type: 'setVisible', visible: false });
send({ type: 'clearActiveTextSource' });
disconnect();
send({ type: 'mineSelected' });
assert.equal(calls.join(','), 'addNote,playAudio,scanSelectedText,-1,0:40,j:true:true,hide,clear');
`);
});
+8
View File
@@ -27,6 +27,14 @@ const REQUIRED_APP_FILES = [
]),
];
const REQUIRED_RESOURCES = [
'hachidori/manifest.json',
'hachidori/settings.html',
'hachidori/subminer-host.js',
'hachidori/vendor/hoshidicts.wasm',
'hachidori/vendor/hoshidicts-threaded.wasm',
'hachidori/vendor/hoshidicts-threaded-idbfs.wasm',
'hachidori/LICENSE',
'hachidori/SOURCE.json',
'yomitan/manifest.json',
'yomitan/data/fonts/kanji-stroke-orders.ttf',
'yomitan/fonts/NotoSansJP-Regular.ttf',
+39 -10
View File
@@ -5,6 +5,7 @@ const path = require('node:path');
const { createRequire } = require('node:module');
const assert = require('node:assert/strict');
const { once } = require('node:events');
const http = require('node:http');
const resources = path.resolve(process.argv[2]);
const archive = path.join(resources, 'app.asar');
@@ -49,12 +50,16 @@ async function smoke() {
{ allowFileAccess: true },
);
assert(extension.id, 'Yomitan extension failed to load');
const hachidori = await session
.fromPartition('persist:hachidori')
.extensions.loadExtension(path.join(resources, 'hachidori'), { allowFileAccess: true });
assert(hachidori.id, 'Hachidori extension failed to load');
const failedRequests = [];
session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => {
if (details.error !== 'net::ERR_ABORTED')
failedRequests.push(`${details.url}: ${details.error}`);
});
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
for (const ui of ['renderer', 'settings', 'syncui']) {
const win = new BrowserWindow({
show: false,
webPreferences: {
@@ -63,22 +68,46 @@ async function smoke() {
},
});
try {
await win.loadFile(
path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`),
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
const loaded = await win.webContents.executeJavaScript(
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
);
if (ui !== 'stats') {
const loaded = await win.webContents.executeJavaScript(
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
);
assert(loaded, `${ui}: shared Japanese font failed to load`);
}
assert(loaded, `${ui}: shared Japanese font failed to load`);
} finally {
win.destroy();
}
}
// The stats dashboard uses HTTP for both assets and API requests in the app.
const { ImmersionTrackerService } = packagedRequire(
'./dist/core/services/immersion-tracker-service.js',
);
const { createStatsApp, startNodeHttpServer } = packagedRequire(
'./dist/core/services/stats-server.js',
);
const tracker = new ImmersionTrackerService({ dbPath: path.join(isolatedData, 'stats.db') });
const statsConfig = { port: 0, staticDir: path.join(archive, 'stats/dist'), tracker };
let statsHttp;
const statsServer = await startNodeHttpServer(
createStatsApp(tracker, statsConfig),
statsConfig,
(listener) => (statsHttp = http.createServer(listener)),
);
const statsWindow = new BrowserWindow({ show: false });
try {
const url = `http://127.0.0.1:${statsHttp.address().port}`;
session.defaultSession.webRequest.onCompleted({ urls: [`${url}/*`] }, (details) => {
if (details.statusCode >= 400) failedRequests.push(`${details.url}: ${details.statusCode}`);
});
await statsWindow.loadURL(url);
assert.equal((await fetch(`${url}/api/stats/overview`)).status, 200);
} finally {
statsWindow.destroy();
await statsServer.close();
tracker.destroy();
}
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
console.log(
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
'Package smoke passed: SQLite, platform FFI, texthooker, both dictionary extensions, UI pages, stats HTTP, shared Japanese font.',
);
}