mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-21 05:01:26 -07:00
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:
11 files changed
+142
-37
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user