feat(hachidori): derive Docker management URL from the linked host

- Default character dictionary uploads to the linked host on port 8780; externalHostManagementUrl is now an optional override
- Warn when the override points at a different machine than the link
- Browser/app hosts report where to import the merged ZIP manually
- Name the management origin when the upload request cannot connect
- Update config descriptions, generated examples, docs, and changelog fragment
This commit is contained in:
sudacode committed 2026-09-29 20:22:07 -07:00
1 parent 4ab5e37bc5
commit b52692d826
11 files changed
+142 -37

No files matched your search

+1 -1
View File
@@ -86,7 +86,7 @@ export function buildCoreConfigOptionRegistry(
kind: 'string',
defaultValue: defaultConfig.hachidori.externalHostManagementUrl,
description:
'Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.',
'Management URL override for character dictionary uploads to a linked Hachidori Docker host. Empty uses the linked host on the Docker default management port.',
},
{
path: 'dictionaryBackend',
+1 -1
View File
@@ -12,7 +12,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Hachidori External Dictionary Imports',
description: [
'Configure the linked Docker host management URL, for example http://127.0.0.1:8780.',
'Override the linked Docker host management URL only for a non-default port or a reverse proxy.',
],
notes: ['Used only while Hachidori is linked to an external host.'],
key: 'hachidori',
@@ -5,7 +5,10 @@ import { once } from 'node:events';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { uploadHachidoriDictionary } from './hachidori-dictionary-import';
import {
resolveHachidoriManagementUrl,
uploadHachidoriDictionary,
} from './hachidori-dictionary-import';
import { importYomitanDictionaryFromZip } from './yomitan-parser-runtime';
import { createDeps } from './yomitan-scan-test-harness';
@@ -42,7 +45,7 @@ test('linked Hachidori uploads replacement bytes, retries a busy host, and never
linked: true,
connected,
address: 'ws://127.0.0.1:8771/link',
host: { dictionaryCount: 8 },
host: { name: 'Hachidori Docker host', dictionaryCount: 8 },
},
},
};
@@ -62,10 +65,7 @@ test('linked Hachidori uploads replacement bytes, retries a busy host, and never
true,
);
assert.equal(attempts, 2);
const errors: string[] = [];
const logger = { error: (...args: unknown[]) => errors.push(args.join(' ')) };
assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger), false);
assert.match(errors.pop() ?? '', /externalHostManagementUrl/);
const logger = { error: () => {} };
connected = false;
assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger, managementUrl), false);
assert.equal(attempts, 2);
@@ -98,3 +98,61 @@ test('Hachidori upload requires a successful import report, not just HTTP succes
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test('management URL comes from the linked Docker host unless overridden', () => {
const docker = { address: 'ws://pve-main:8771/link', name: 'Hachidori Docker host' };
const warnings: string[] = [];
const warn = (message: string) => warnings.push(message);
assert.equal(
resolveHachidoriManagementUrl(docker, '', 'merged.zip', warn),
'http://pve-main:8780',
);
assert.equal(
resolveHachidoriManagementUrl(docker, 'http://pve-main:9000', 'merged.zip', warn),
'http://pve-main:9000',
);
assert.deepEqual(warnings, []);
assert.equal(
resolveHachidoriManagementUrl(
{ address: 'ws://127.0.0.1:8771/link', name: 'Hachidori Docker host' },
'http://localhost:8780',
'merged.zip',
warn,
),
'http://localhost:8780',
);
assert.deepEqual(warnings, []);
assert.equal(
resolveHachidoriManagementUrl(docker, 'http://127.0.0.1:8780', 'merged.zip', warn),
'http://127.0.0.1:8780',
);
assert.match(warnings[0] ?? '', /linked to pve-main/);
});
test('browser and app hosts without an override explain the manual import', () => {
assert.throws(
() =>
resolveHachidoriManagementUrl(
{ address: 'ws://desktop:8771/link', name: 'Chrome' },
'',
'/dicts/merged.zip',
),
/Chrome at desktop cannot receive dictionary uploads\. Import \/dicts\/merged\.zip/,
);
});
test('unreachable management API names the origin instead of a bare fetch failure', async () => {
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
await writeFile(zipPath, 'archive');
const server = createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
assert.ok(address && typeof address === 'object');
await new Promise<void>((resolve) => server.close(() => resolve()));
const origin = `http://127.0.0.1:${address.port}`;
await assert.rejects(
uploadHachidoriDictionary(zipPath, origin),
new RegExp(`Could not reach the Hachidori management API at ${origin}: `),
);
});
@@ -1,33 +1,70 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { parseHachidoriManagementUrl } from '../../../shared/hachidori-sharing';
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.
export const HACHIDORI_DOCKER_HOST_NAME = 'Hachidori Docker host';
export const HACHIDORI_DOCKER_MANAGEMENT_PORT = 8780;
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]']);
function sameMachine(left: string, right: string): boolean {
return left === right || (LOOPBACK_HOSTNAMES.has(left) && LOOPBACK_HOSTNAMES.has(right));
}
// Picks the management origin that holds the linked host's dictionaries. The link
// address names the machine; `override` (hachidori.externalHostManagementUrl, already
// normalized to an origin) covers a non-default port or a reverse proxy.
export function resolveHachidoriManagementUrl(
host: Pick<Extract<HachidoriHostStatus, { kind: 'connected' }>, 'address' | 'name'>,
override: string,
zipPath: string,
warn?: (message: string) => void,
): string {
const linkHostname = new URL(host.address).hostname;
if (override) {
const overrideHostname = new URL(override).hostname;
if (!sameMachine(overrideHostname, linkHostname)) {
warn?.(
`hachidori.externalHostManagementUrl (${override}) points at ${overrideHostname}, but Hachidori is linked to ${linkHostname}; uploads may miss the linked host.`,
);
}
return override;
}
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.`,
);
}
return new URL(`http://${linkHostname}:${HACHIDORI_DOCKER_MANAGEMENT_PORT}`).origin;
}
// Upload from the main process: extension blob URLs cannot cross the sharing link,
// and the Docker management API deliberately rejects browser cross-origin writes.
export async function uploadHachidoriDictionary(
zipPath: string,
managementUrl: string,
): Promise<void> {
const origin = parseHachidoriManagementUrl(managementUrl);
if (!origin) {
throw new Error(
'Set hachidori.externalHostManagementUrl to the linked Docker host management URL to sync character dictionaries.',
);
}
export async function uploadHachidoriDictionary(zipPath: string, origin: string): Promise<void> {
const url = new URL('/import', origin);
url.searchParams.set('name', path.basename(zipPath));
url.searchParams.set('replace', 'true');
const bytes = await readFile(zipPath);
const signal = AbortSignal.timeout(300_000);
for (;;) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/zip' },
body: bytes,
signal,
redirect: 'error',
});
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/zip' },
body: bytes,
signal,
redirect: 'error',
});
} catch (error) {
// undici reports every network failure as "fetch failed"; the cause says why.
const cause = error instanceof Error && error.cause instanceof Error ? error.cause : error;
const reason = cause instanceof Error ? cause.message : String(cause);
throw new Error(`Could not reach the Hachidori management API at ${origin}: ${reason}`);
}
if (response.status === 409) {
await response.body?.cancel();
await delay(500, undefined, { signal });
@@ -1,7 +1,10 @@
import type { BrowserWindow, Extension, Session } from 'electron';
import type { AnkiConnectConfig } from '../../../types';
import { buildHachidoriAnkiHints } from './hachidori-anki-settings';
import { uploadHachidoriDictionary } from './hachidori-dictionary-import';
import {
resolveHachidoriManagementUrl,
uploadHachidoriDictionary,
} from './hachidori-dictionary-import';
import {
buildHachidoriSharingScript,
parseHachidoriHostStatus,
@@ -1815,7 +1818,13 @@ export async function importYomitanDictionaryFromZip(
if (host.kind === 'disconnected' || host.kind === 'unavailable')
throw new Error(host.message);
if (host.kind === 'connected') {
await uploadHachidoriDictionary(normalizedZipPath, hachidoriManagementUrl);
const origin = resolveHachidoriManagementUrl(
host,
hachidoriManagementUrl,
normalizedZipPath,
logger.warn,
);
await uploadHachidoriDictionary(normalizedZipPath, origin);
const window = deps.getYomitanParserWindow();
if (window) clearYomitanParserCachesForWindow(window);
logger.info?.(
+1
View File
@@ -2727,6 +2727,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
{
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
warn: (message, ...args) => logger.warn(message, ...args),
},
configService.getConfig().hachidori.externalHostManagementUrl,
);