mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
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:
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.
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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')
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user