From d7e1d7edc66f96d1bc487b96ac1165d49df099d3 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 22 Sep 2026 12:26:37 -0700 Subject: [PATCH] refactor: configure Hachidori in the SubMiner build --- docs/architecture/README.md | 2 +- scripts/build-hachidori.mjs | 14 ++++++++++ scripts/build-hachidori.test.ts | 37 +++++++++++++++++++++++++ scripts/hachidori-host.test.ts | 10 +++---- src/renderer/handlers/keyboard.ts | 7 ++--- src/renderer/handlers/mouse.ts | 7 ++--- src/renderer/modals/subtitle-sidebar.ts | 17 ++++++++---- src/renderer/yomitan-popup.test.ts | 25 +++++++++++++++++ src/renderer/yomitan-popup.ts | 13 +++++++++ vendor/hachidori | 2 +- 10 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 scripts/build-hachidori.test.ts diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 03062aad..c4fc78ad 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -48,7 +48,7 @@ The dictionary backend is selected once at startup by `dictionaryBackend`. Yomit `setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart. -`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. Its local content bridge implements SubMiner's existing popup events and commands. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes. +`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, and removes the unsupported `userScripts` permission only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes. - Small units, explicit boundaries - Composition over monoliths diff --git a/scripts/build-hachidori.mjs b/scripts/build-hachidori.mjs index 8a004ea1..61cd87b4 100644 --- a/scripts/build-hachidori.mjs +++ b/scripts/build-hachidori.mjs @@ -30,6 +30,20 @@ for (const file of [ fs.rmSync(output, { recursive: true, force: true }); fs.mkdirSync(output, { recursive: true }); fs.cpSync(extension, output, { recursive: true }); +// Host configuration belongs in the staged copy, leaving the fork usable in Chrome. +const overlayPath = path.join(output, 'overlay-mode.js'); +let overlay = fs.readFileSync(overlayPath, 'utf8'); +for (const [original, replacement] of [ + ['export const OVERLAY_MODE = false;', 'export const OVERLAY_MODE = true;'], + ['customJavaScript: !IS_FIREFOX,', 'customJavaScript: false,'], +]) { + if (!overlay.includes(original)) + throw new Error(`Hachidori host configuration changed upstream: ${original}`); + overlay = overlay.replace(original, replacement); +} +fs.writeFileSync(overlayPath, overlay); +manifest.permissions = manifest.permissions.filter((permission) => permission !== 'userScripts'); +fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n'); for (const file of ['LICENSE', 'SOURCE.json', 'README.md']) { fs.copyFileSync(path.join(source, file), path.join(output, file)); } diff --git a/scripts/build-hachidori.test.ts b/scripts/build-hachidori.test.ts new file mode 100644 index 00000000..cb533fa9 --- /dev/null +++ b/scripts/build-hachidori.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +test('Hachidori staging configures Electron without changing the fork source', async () => { + const files = ['overlay-mode.js', 'manifest.json']; + const source = (file: string) => + new URL(`../vendor/hachidori/extension/${file}`, import.meta.url); + const before = files.map((file) => readFileSync(source(file), 'utf8')); + + execFileSync(process.execPath, [ + fileURLToPath(new URL('./build-hachidori.mjs', import.meta.url)), + ]); + + assert.deepEqual( + files.map((file) => readFileSync(source(file), 'utf8')), + before, + ); + const staged = await import(new URL('../build/hachidori/overlay-mode.js', import.meta.url).href); + assert.equal(staged.OVERLAY_MODE, true); + assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false); + const original = await import(source('overlay-mode.js').href); + assert.equal(original.OVERLAY_MODE, false); + assert.equal(original.HOST_CAPABILITIES.customJavaScript, true); + const manifest = JSON.parse( + readFileSync(new URL('../build/hachidori/manifest.json', import.meta.url), 'utf8'), + ); + const originalManifest = JSON.parse(readFileSync(source('manifest.json'), 'utf8')); + assert.deepEqual(manifest, { + ...originalManifest, + permissions: originalManifest.permissions.filter( + (permission: string) => permission !== 'userScripts', + ), + }); +}); diff --git a/scripts/hachidori-host.test.ts b/scripts/hachidori-host.test.ts index 567fc3fc..cda320a3 100644 --- a/scripts/hachidori-host.test.ts +++ b/scripts/hachidori-host.test.ts @@ -25,7 +25,7 @@ function run(code: string) { }); } -test('Hachidori publishes popup state independently from successful lookups', () => { +test('Hachidori marks popup state independently from successful lookups', () => { run(` const events = []; for (const name of ['yomitan-popup-shown', 'yomitan-popup-hidden', 'subminer-yomitan-lookup']) { @@ -33,13 +33,13 @@ test('Hachidori publishes popup state independently from successful lookups', () } const attributes = new Map(); const host = { setAttribute: (name, value) => attributes.set(name, value) }; - SubMinerHachidori.attention(host, true); + SubMinerHachidori.markHost(host, true); assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'true'); - assert.equal(events.join(','), 'yomitan-popup-shown'); + assert.equal(events.join(','), ''); SubMinerHachidori.lookup(); - SubMinerHachidori.attention(host, false); + SubMinerHachidori.markHost(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'); + assert.equal(events.join(','), 'subminer-yomitan-lookup'); `); }); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 1e1bbe19..9e694ea5 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -2,8 +2,7 @@ import type { CompiledSessionBinding, PrimarySubMode, ShortcutsConfig } from '.. import type { RendererContext } from '../context'; import { createMpvInputForwarding } from './mpv-input-forwarding'; import { - YOMITAN_POPUP_HIDDEN_EVENT, - YOMITAN_POPUP_SHOWN_EVENT, + registerDictionaryPopupVisibilityListener, YOMITAN_POPUP_COMMAND_EVENT, isYomitanPopupVisible, isYomitanPopupIframe, @@ -1064,7 +1063,7 @@ export function createKeyboardHandlers( subtree: true, }); - window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { + registerDictionaryPopupVisibilityListener('hidden', () => { clearNativeSubtitleSelection(); if (!ctx.state.keyboardDrivenModeEnabled) { syncKeyboardTokenSelection(); @@ -1072,7 +1071,7 @@ export function createKeyboardHandlers( } restoreOverlayKeyboardFocus(); }); - window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { + registerDictionaryPopupVisibilityListener('shown', () => { if (!ctx.state.keyboardDrivenModeEnabled) { return; } diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 36959d0e..2e73e4f4 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -1,10 +1,9 @@ import type { ModalStateReader, RendererContext } from '../context'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; import { - YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_MOUSE_ENTER_EVENT, YOMITAN_POPUP_MOUSE_LEAVE_EVENT, - YOMITAN_POPUP_SHOWN_EVENT, + registerDictionaryPopupVisibilityListener, PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS, isYomitanPopupVisible, isYomitanPopupIframe, @@ -470,7 +469,7 @@ export function createMouseHandlers( function setupYomitanObserver(): void { reconcilePopupInteraction({ allowPause: true }); - window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { + registerDictionaryPopupVisibilityListener('shown', () => { reconcilePopupInteraction({ assumeVisible: true, allowPause: true, @@ -478,7 +477,7 @@ export function createMouseHandlers( }); }); - window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { + registerDictionaryPopupVisibilityListener('hidden', () => { disablePopupInteractionIfIdle(); }); diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 9eab6efe..40ee1a52 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -12,8 +12,7 @@ import { hasSubtitleSidebarSelection, } from './subtitle-sidebar-selection.js'; import { - YOMITAN_POPUP_HIDDEN_EVENT, - YOMITAN_POPUP_SHOWN_EVENT, + registerDictionaryPopupVisibilityListener, isYomitanPopupVisible, } from '../yomitan-popup.js'; @@ -823,12 +822,18 @@ export function createSubtitleSidebarModal( syncEmbeddedSidebarLayout(); }; window.addEventListener('resize', resizeHandler); - window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown); - window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden); + const disposeShown = registerDictionaryPopupVisibilityListener( + 'shown', + handleYomitanPopupShown, + ); + const disposeHidden = registerDictionaryPopupVisibilityListener( + 'hidden', + handleYomitanPopupHidden, + ); disposeDomEvents = () => { window.removeEventListener('resize', resizeHandler); - window.removeEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown); - window.removeEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden); + disposeShown(); + disposeHidden(); disposeDomEvents = null; }; } diff --git a/src/renderer/yomitan-popup.test.ts b/src/renderer/yomitan-popup.test.ts index d38f7ccd..8736aeea 100644 --- a/src/renderer/yomitan-popup.test.ts +++ b/src/renderer/yomitan-popup.test.ts @@ -5,8 +5,33 @@ import { YOMITAN_POPUP_VISIBLE_HOST_SELECTOR, isYomitanPopupVisible, registerYomitanLookupListener, + registerDictionaryPopupVisibilityListener, } from './yomitan-popup.js'; +test('native popup attention events from either backend have the same lifecycle', () => { + for (const backend of ['yomitan', 'hachidori']) { + const target = new EventTarget(); + const calls: string[] = []; + const disposeShown = registerDictionaryPopupVisibilityListener( + 'shown', + () => calls.push('shown'), + target, + ); + const disposeHidden = registerDictionaryPopupVisibilityListener( + 'hidden', + () => calls.push('hidden'), + target, + ); + target.dispatchEvent(new CustomEvent(`${backend}-popup-shown`)); + target.dispatchEvent(new CustomEvent(`${backend}-popup-hidden`)); + disposeShown(); + disposeHidden(); + target.dispatchEvent(new CustomEvent(`${backend}-popup-shown`)); + target.dispatchEvent(new CustomEvent(`${backend}-popup-hidden`)); + assert.deepEqual(calls, ['shown', 'hidden']); + } +}); + test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', () => { const target = new EventTarget(); const calls: string[] = []; diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index e3301e5e..555391c4 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -11,6 +11,19 @@ export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command'; export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup'; export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup'; +// Only the active backend injects a reader. Consume its native attention events. +export function registerDictionaryPopupVisibilityListener( + state: 'shown' | 'hidden', + listener: () => void, + target: EventTarget = window, +): () => void { + const events = [`yomitan-popup-${state}`, `hachidori-popup-${state}`]; + for (const event of events) target.addEventListener(event, listener); + return () => { + for (const event of events) target.removeEventListener(event, listener); + }; +} + export function registerYomitanLookupListener( target: EventTarget = window, listener: () => void, diff --git a/vendor/hachidori b/vendor/hachidori index 1a47c444..1cb50adc 160000 --- a/vendor/hachidori +++ b/vendor/hachidori @@ -1 +1 @@ -Subproject commit 1a47c4442056b9f964a75bfdd31f0f03a809655b +Subproject commit 1cb50adcd4b12d369b772e7d9d03e9b3544e7602