feat(hachidori): send character dictionaries over the link to Hachidori hosts

Hachidori 0.2.3 accepts dictionary ZIPs as chunked upload requests: an
unlinked install imports them itself and a linked one forwards them to its
host. The parser bridge now imports through that path with replace, retrying
a busy commit with the same upload. Linked hosts without linked-import-v1
(hachidori-docker, older Hachidori) keep the management API upload.
This commit is contained in:
sudacode committed 2026-10-05 09:10:33 -07:00
1 parent e25d472d82
commit e1c4d6ca94
11 files changed
+195 -25

No files matched your search

@@ -75,6 +75,60 @@ test('linked Hachidori uploads replacement bytes, retries a busy host, and never
}
});
test('a linked host that accepts imports gets the ZIP over the link, not its management API', async () => {
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
await writeFile(zipPath, 'PK-linked');
const linkedUploads: string[] = [];
// Serves both the parser window (sharing status) and the settings window (import).
const runScript = async (script: string): Promise<unknown> => {
if (script.includes('hd_sharing_status')) {
return {
ok: true,
sharing: {
client: {
linked: true,
connected: true,
address: 'ws://desktop:8771/link',
host: { name: 'Helium', dictionaryCount: 8, capabilities: ['linked-import-v1'] },
},
},
};
}
const archiveUrl = /importDictionaryArchiveUrl\(\s*("[^"]+")/.exec(script)?.[1];
if (archiveUrl) linkedUploads.push(await (await fetch(JSON.parse(archiveUrl))).text());
return true;
};
const settingsWindow = {
isDestroyed: () => false,
destroy: () => undefined,
webContents: { executeJavaScript: runScript },
};
const deps = {
...createDeps(runScript, {
createYomitanExtensionWindow: async () => settingsWindow,
}),
getYomitanExt: () => ({
id: 'hachi',
name: 'Hachidori',
version: '1',
path: '',
url: '',
manifest: {},
}),
};
// Nothing listens here: reaching the management API would fail the import.
assert.equal(
await importYomitanDictionaryFromZip(
zipPath,
deps,
{ error: assert.fail },
'http://127.0.0.1:9',
),
true,
);
assert.deepEqual(linkedUploads, ['PK-linked']);
});
test('Hachidori upload requires a successful import report, not just HTTP success', async () => {
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
await writeFile(zipPath, 'bad archive');
@@ -129,7 +183,7 @@ test('management URL comes from the linked Docker host unless overridden', () =>
assert.match(warnings[0] ?? '', /linked to pve-main/);
});
test('browser and app hosts without an override explain the manual import', () => {
test('hosts without linked imports or an override explain the update or manual import', () => {
assert.throws(
() =>
resolveHachidoriManagementUrl(
@@ -137,7 +191,7 @@ test('browser and app hosts without an override explain the manual import', () =
'',
'/dicts/merged.zip',
),
/Chrome at desktop cannot receive dictionary uploads\. Import \/dicts\/merged\.zip/,
/Chrome at desktop cannot receive dictionary uploads\. Update its Hachidori to 0\.2\.3 or later, or import \/dicts\/merged\.zip/,
);
});
@@ -3,8 +3,9 @@ import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import type { HachidoriHostStatus } from '../../../shared/hachidori-sharing';
// hachidori-docker names itself this in the sharing hello; browser and app hosts
// report their own name and have no management API to upload to.
// hachidori-docker names itself this in the sharing hello. Hosts that advertise
// linked imports take uploads over the link instead; this management API covers
// hachidori-docker and is the only route to it.
export const HACHIDORI_DOCKER_HOST_NAME = 'Hachidori Docker host';
export const HACHIDORI_DOCKER_MANAGEMENT_PORT = 8780;
@@ -35,7 +36,7 @@ export function resolveHachidoriManagementUrl(
}
if (host.name !== HACHIDORI_DOCKER_HOST_NAME) {
throw new Error(
`The linked ${host.name} at ${linkHostname} cannot receive dictionary uploads. Import ${zipPath} from its Hachidori settings, or link a Hachidori Docker host.`,
`The linked ${host.name} at ${linkHostname} cannot receive dictionary uploads. Update its Hachidori to 0.2.3 or later, or import ${zipPath} from its Hachidori settings.`,
);
}
return new URL(`http://${linkHostname}:${HACHIDORI_DOCKER_MANAGEMENT_PORT}`).origin;
@@ -35,6 +35,29 @@ function lookupResult(matched: string, expression: string, reading: string, dict
};
}
type UploadReply = Record<string, unknown>;
// The parts of vendor/hachidori/extension/linked-import.js the harness drives.
type LinkedImportModule = {
ENGINE_BUSY_CODE: string;
createUploadHost(options: {
store: {
append(token: string, data: string): void;
discard(token: string): void;
};
importUpload(
token: string,
upload: { fileName: string; replace: boolean },
): Promise<UploadReply>;
setTimer(): number;
clearTimer(): void;
}): {
begin(message: UploadReply, owner: string): UploadReply;
chunk(message: UploadReply, owner: string): Promise<UploadReply>;
commit(message: UploadReply, owner: string): Promise<UploadReply>;
abort(message: UploadReply, owner: string): UploadReply;
};
};
async function createHarness(emptyLibrary = false) {
const messages: Array<Record<string, unknown>> = [];
let dictionaryRevision = 2;
@@ -62,11 +85,42 @@ async function createHarness(emptyLibrary = false) {
let proxyUrl: unknown;
let loadingStatusReplies = 0;
let busyEngineReplies = 0;
let busyCommits = 0;
const uploads: Array<{ fileName: string; replace: boolean; bytes: Buffer }> = [];
const apiModule: unknown = await import(
pathToFileURL(path.join(extensionPath, 'api-host.js')).href
);
const linkedImportModule = (await import(
pathToFileURL(path.join(extensionPath, 'linked-import.js')).href
)) as LinkedImportModule;
// Hachidori's own upload host, with the engine import replaced by a recorder.
const uploadParts = new Map<string, Buffer[]>();
const uploadHost = linkedImportModule.createUploadHost({
store: {
append: (token: string, data: string) => {
uploadParts.set(token, [...(uploadParts.get(token) ?? []), Buffer.from(data, 'base64')]);
},
discard: (token: string) => {
uploadParts.delete(token);
},
},
importUpload: async (
token: string,
{ fileName, replace }: { fileName: string; replace: boolean },
) => {
if (busyCommits > 0) {
busyCommits -= 1;
return { ok: false, error: 'busy', errorCode: linkedImportModule.ENGINE_BUSY_CODE };
}
uploads.push({ fileName, replace, bytes: Buffer.concat(uploadParts.get(token) ?? []) });
return { ok: true, report: { success: true } };
},
setTimer: () => 0,
clearTimer: () => {},
});
const context = vm.createContext({
__testApiModule: apiModule,
__testLinkedImportModule: linkedImportModule,
setTimeout,
crypto,
URL,
@@ -89,6 +143,23 @@ async function createHarness(emptyLibrary = false) {
getManifest: () => ({ version: 'test' }),
sendMessage: async (message: Record<string, unknown>) => {
messages.push(structuredClone(message));
if (message.target === 'hachidori-linked-import') {
// Mirrors background.js answerUploadRequest: commit answers with the import reply.
try {
switch (message.type) {
case 'hd_import_begin':
return { ok: true, ...uploadHost.begin(message, 'local') };
case 'hd_import_chunk':
return { ok: true, ...(await uploadHost.chunk(message, 'local')) };
case 'hd_import_abort':
return { ok: true, ...uploadHost.abort(message, 'local') };
default:
return await uploadHost.commit(message, 'local');
}
} catch (error) {
return { ok: false, error: (error as Error).message };
}
}
if (message.type === 'hd_status') {
const loading = loadingStatusReplies > 0;
if (loading) loadingStatusReplies -= 1;
@@ -151,8 +222,6 @@ async function createHarness(emptyLibrary = false) {
};
case 'hd_anki_submit':
return { ok: true, state: 'added', noteId: 19 };
case 'hd_import':
return { ok: true, report: { success: true } };
case 'hd_remove':
return { ok: true };
default:
@@ -167,7 +236,9 @@ async function createHarness(emptyLibrary = false) {
const script = HACHIDORI_PARSER_BRIDGE_SCRIPT.replace(
"await import('./api-host.js')",
'__testApiModule',
).replace("await import('./reader-options.js')", 'Promise.resolve()');
)
.replace("await import('./linked-import.js')", '__testLinkedImportModule')
.replace("await import('./reader-options.js')", 'Promise.resolve()');
await vm.runInContext(script, context);
const run = async (code: string): Promise<unknown> =>
structuredClone(await vm.runInContext(code, context));
@@ -209,6 +280,10 @@ async function createHarness(emptyLibrary = false) {
setBusyEngineReplies: (count: number) => {
busyEngineReplies = count;
},
setBusyCommits: (count: number) => {
busyCommits = count;
},
uploads: () => uploads,
};
}
@@ -464,17 +539,19 @@ test('Hachidori mining requests render native dictionary aliases and frequency m
);
});
test('Hachidori settings automation imports ZIP bytes and removes the matching dictionary ID', async () => {
test('Hachidori settings automation uploads ZIP bytes as a replacement and removes the matching dictionary ID', async () => {
const harness = await createHarness();
// The engine is mid-change for the first commit; the same upload must still land once.
harness.setBusyCommits(1);
await harness.run(
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEs=', 'characters.zip')",
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEstdGVzdA==', 'characters.zip')",
);
assert.deepEqual(harness.uploads(), [
{ fileName: 'characters.zip', replace: true, bytes: Buffer.from('PK-test') },
]);
await harness.run(
`__subminerYomitanSettingsAutomation.deleteDictionary(${JSON.stringify(characterDictionary)})`,
);
const imported = harness.messages.find((message) => message.type === 'hd_import');
assert.equal(imported?.fileName, 'characters.zip');
assert.match(String(imported?.blobUrl), /^blob:/);
const removed = harness.messages.find((message) => message.type === 'hd_remove');
assert.equal(removed?.id, 'names');
});
@@ -9,6 +9,7 @@ export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
(async () => {
if (globalThis.__subminerDictionarySendMessage) return;
const { createApiHost } = await import('./api-host.js');
const { uploadDictionary } = await import('./linked-import.js');
await import('./reader-options.js');
const send = async (type, fields = {}, target = 'hoshidicts-offscreen') => {
const reply = await chrome.runtime.sendMessage({
@@ -207,13 +208,26 @@ export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
globalThis.__subminerDictionarySendMessage = ({ action, params }, callback) => {
void invoke(action, params).then(result => callback({ result }), error => callback({ error: { message: error.message } }));
};
// Archives go through Hachidori's upload requests: an unlinked install imports
// the ZIP itself and a linked one forwards it to its host. A busy engine keeps
// the upload open, so the same commit is sent again.
const sendUpload = async (type, fields) => {
const deadline = Date.now() + 300000;
for (;;) {
const reply = await chrome.runtime.sendMessage({
target: 'hachidori-linked-import', type, requestId: crypto.randomUUID(), ...fields,
});
if (type !== 'hd_import_commit' || reply?.errorCode !== 'engine-mutating' || Date.now() >= deadline) {
return reply;
}
await sleep(500);
}
};
async function importArchive(blob, fileName) {
const blobUrl = URL.createObjectURL(blob);
try {
const reply = await engine('hd_import', { blobUrl, fileName });
if (reply.report?.success !== true) throw new Error(reply.report?.error || 'Hachidori dictionary import failed');
} finally {
URL.revokeObjectURL(blobUrl);
await waitForEngineReady();
const reply = await uploadDictionary({ blob, fileName, replace: true, send: sendUpload });
if (reply?.report?.success !== true) {
throw new Error(reply?.report?.error || reply?.error || 'Hachidori dictionary import failed');
}
}
globalThis.__subminerYomitanSettingsAutomation = {
@@ -7,6 +7,7 @@ import {
} from './hachidori-dictionary-import';
import {
buildHachidoriSharingScript,
HACHIDORI_LINKED_IMPORT_CAPABILITY,
parseHachidoriHostStatus,
type HachidoriSharingRequest,
} from '../../../shared/hachidori-sharing';
@@ -1817,7 +1818,12 @@ export async function importYomitanDictionaryFromZip(
const host = await requestHachidoriSharing({ type: 'hd_sharing_status' }, deps, logger);
if (host.kind === 'disconnected' || host.kind === 'unavailable')
throw new Error(host.message);
if (host.kind === 'connected') {
// Hosts with linked imports take the ZIP over the link like a local import,
// below. Older hosts and hachidori-docker need their management API.
if (
host.kind === 'connected' &&
!host.capabilities.includes(HACHIDORI_LINKED_IMPORT_CAPABILITY)
) {
const origin = resolveHachidoriManagementUrl(
host,
hachidoriManagementUrl,
@@ -881,6 +881,7 @@ test('Hachidori setup gates on the linked host instead of local dictionaries', a
address: 'ws://127.0.0.1:8771/link',
name: 'Docker',
dictionaryCount: 2,
capabilities: [],
};
const service = createFirstRunSetupService({
configDir: dir,
@@ -905,6 +906,7 @@ test('Hachidori setup gates on the linked host instead of local dictionaries', a
address: 'ws://127.0.0.1:8771/link',
name: 'Docker',
dictionaryCount: 0,
capabilities: [],
};
assert.equal((await service.getSetupStatus()).canFinish, false);
// A reachable host with no dictionaries does reopen setup.
+2 -1
View File
@@ -52,7 +52,7 @@ test('sharing script safely links an address and checks live engine inventory',
linked: true,
connected: true,
address,
host: { name: 'Host', dictionaryCount: 99 },
host: { name: 'Host', dictionaryCount: 99, capabilities: ['linked-import-v1', 7] },
},
},
};
@@ -66,6 +66,7 @@ test('sharing script safely links an address and checks live engine inventory',
address,
name: 'Host',
dictionaryCount: 2,
capabilities: ['linked-import-v1'],
});
});
+14 -1
View File
@@ -1,9 +1,19 @@
export type HachidoriHostStatus =
| { kind: 'local' }
| { kind: 'connected'; address: string; name: string; dictionaryCount: number }
| {
kind: 'connected';
address: string;
name: string;
dictionaryCount: number;
// Advertised by the host in its sharing hello, e.g. HACHIDORI_LINKED_IMPORT_CAPABILITY.
capabilities: readonly string[];
}
| { kind: 'disconnected'; address: string; message: string }
| { kind: 'unavailable'; message: string };
// Hosts with this capability (Hachidori 0.2.3+) accept dictionary ZIPs over the link.
export const HACHIDORI_LINKED_IMPORT_CAPABILITY = 'linked-import-v1';
export type HachidoriSharingRequest =
| { type: 'hd_sharing_status' }
| { type: 'hd_sharing_client_link'; address: string }
@@ -68,6 +78,9 @@ export function parseHachidoriHostStatus(reply: unknown): HachidoriHostStatus {
address: client.address,
name: typeof host.name === 'string' ? host.name : 'Hachidori',
dictionaryCount: host.dictionaryCount,
capabilities: Array.isArray(host.capabilities)
? host.capabilities.filter((entry): entry is string => typeof entry === 'string')
: [],
};
}