fix(dict): restore MDD eager init; CSP-safe audio handler; gate binary uploads on sync category (#4337)

* fix(dict): restore MDD eager init; CSP-safe Vocabulary.com audio handler

Two MDict fixes:

1. **Regression**: #4334 turned on `MDD.create(..., { lazy: true })` so
   the audio / resource side of every MDict would skip the upfront
   key-block decode + sort. That's the right trade-off on the MDX side
   (millions of headwords, ~80 s init saving), but it doesn't transfer
   to MDDs: js-mdict's lazy lookup binary-searches `keyInfoList`
   envelopes with `comp` (localeCompare), and MDDs store keys in
   byte-sorted order with `\` prefixes and case folding. The two orders
   disagree just often enough that `mdd.locateBytes('sound.png')`
   misses on resource paths that do exist — the icon's CSS
   `background-image: url(sound.png)` then stays unresolved and the
   speaker glyph disappears. Bisected to 93abca89 (#4334). Drop the
   flag for MDDs; MDXs keep the lazy win.

2. **New**: a CSP-safe replacement for the `onclick="v0r.v(this,'KEY')"`
   audio handler used by Vocabulary.com-derived MDicts. The dict's
   `j.js` is never loaded inside our shadow root (we don't execute
   MDX-supplied JavaScript), so without intervention the inline
   handler would throw `ReferenceError: v0r is not defined` on every
   click. The new helper parses the audio key from any matching
   onclick, strips that one attribute, and binds our own listener that
   probes the companion MDD for `<KEY>.mp3` / `<KEY>.m4a` / etc. and
   plays the bytes through an `<Audio>` element. Other unrelated
   inline handlers are deliberately left alone — they were no-ops
   already (their helper JS isn't loaded), and CSS rules sometimes
   match on `[onclick]` attribute presence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): skip binary upload when sync category is disabled

`publishReplicaUpsert` already gates the metadata row on
`isSyncCategoryEnabled(kind)` so a user who turned dictionary / font /
texture sync off in Manage Sync never publishes a row for that kind.
`queueReplicaBinaryUpload` had no such gate, so the binary side still
fired: a 250 MB MDict bundle or a few hundred MB of font files would
still upload to cloud storage with no replica row to reference them.

Mirror the gate here. The `kind` parameter (`dictionary`, `font`,
`texture`) lines up with `SyncCategory` names verbatim, so a single
`isSyncCategoryEnabled(kind)` check at the top of the function is all
it takes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-28 16:13:18 +08:00
committed by GitHub
parent 648c35b334
commit 744d5b3a03
3 changed files with 146 additions and 1 deletions
@@ -16,8 +16,10 @@ import { getAccessToken } from '@/utils/access';
import { queueDictionaryBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import { clearReplicaAdapters, registerReplicaAdapter } from '@/services/sync/replicaRegistry';
import { dictionaryAdapter } from '@/services/sync/adapters/dictionary';
import { useSettingsStore } from '@/store/settingsStore';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { AppService } from '@/types/system';
import type { SystemSettings } from '@/types/settings';
const mockIsReady = transferManager.isReady as ReturnType<typeof vi.fn>;
const mockQueueReplicaUpload = transferManager.queueReplicaUpload as ReturnType<typeof vi.fn>;
@@ -172,4 +174,29 @@ describe('queueDictionaryBinaryUpload', () => {
await queueDictionaryBinaryUpload(baseDict(), fakeAppService as unknown as AppService);
expect(close).toHaveBeenCalled();
});
test('no-ops when the dictionary sync category is disabled in Manage Sync', async () => {
// Sister-side gate to `publishReplicaUpsert`: when the user has turned
// dictionary sync off, the bundle binaries must also stay on the
// device. Otherwise the (potentially hundreds-of-MB) upload still
// fires even though the metadata row never publishes.
const prevSettings = useSettingsStore.getState().settings;
useSettingsStore.setState({
settings: {
...(prevSettings ?? ({} as SystemSettings)),
syncCategories: { ...(prevSettings?.syncCategories ?? {}), dictionary: false },
} as SystemSettings,
});
try {
mockIsReady.mockReturnValue(true);
const fakeAppService = makeFakeAppService({
'bundle-dir/webster.mdx': 1_000_000,
}) as unknown as AppService;
const result = await queueDictionaryBinaryUpload(baseDict(), fakeAppService);
expect(result).toBe(null);
expect(mockQueueReplicaUpload).not.toHaveBeenCalled();
} finally {
useSettingsStore.setState({ settings: prevSettings });
}
});
});
@@ -222,6 +222,103 @@ async function resolveCssUrls(
});
}
// Pattern for the audio-play handler used by Vocabulary.com-derived MDicts:
//
// <img src="..." onclick="v0r.v(this,'E/BDAWTHU6YF46')" class="m">
//
// The first argument is the element itself; the second is the key into the
// companion MDD's audio store. The bundled `j.js` script (which we ignore for
// XSS reasons — never execute MDX-supplied JavaScript inside the shadow root)
// resolves that key to a path like `E/BDAWTHU6YF46.mp3` and plays it. We do
// the same lookup ourselves from a CSP-safe click handler.
const V0R_PLAY_RX = /v0r\.v\s*\(\s*this\s*,\s*['"]([^'"]+)['"]\s*\)/i;
const AUDIO_EXTS = ['.mp3', '.m4a', '.aac', '.ogg', '.opus', '.wav'] as const;
async function wireMdictAudioOnclick(
container: HTMLElement,
mdds: MDDInstance[],
trackedUrls: string[],
): Promise<void> {
// Note: we deliberately leave `<script>` tags and other inline `onclick`
// attributes in place. innerHTML parsing does NOT execute scripts, and
// inline handlers attached via attributes only fire when the element is
// actually clicked — most dictionaries' MDX layouts include them only to
// power their own helper JS (which we never load) and leaving them alone
// avoids touching CSS rules that depend on attribute presence
// (`[onclick]`, `:empty` interactions, etc.). Only the audio-play
// pattern below gets rewritten so the icon actually does something.
const elements = Array.from(container.querySelectorAll<HTMLElement>('[onclick]'));
for (const el of elements) {
const onclick = el.getAttribute('onclick') ?? '';
const playMatch = onclick.match(V0R_PLAY_RX);
if (!playMatch || !mdds.length) continue;
const key = playMatch[1]!.trim();
if (!key) continue;
// Drop the inline handler ONLY for the v0r.v audio call we're about to
// replace. The dict's `j.js` isn't loaded inside the shadow root, so the
// original handler would throw `ReferenceError: v0r is not defined` on
// every click; our listener handles the playback instead.
el.removeAttribute('onclick');
el.style.cursor ||= 'pointer';
el.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
let url = el.getAttribute('data-mdd-audio');
if (!url) {
// Candidate paths: vocabulary.com's MDD stores audio as
// `<KEY>.mp3` (with `/` -> `\` normalisation handled inside
// js-mdict's `locate` step). Other Vocabulary-derived packs use a
// leading `audio/` directory. Try each in order; first hit wins.
const candidates: string[] = [];
for (const ext of AUDIO_EXTS) candidates.push(`${key}${ext}`, `audio/${key}${ext}`);
console.log(
`[MDD-AUDIO] probe key=${key} candidates=${candidates.length} mdds=${mdds.length}`,
);
outer: for (const path of candidates) {
for (let i = 0; i < mdds.length; i++) {
const mdd = mdds[i]!;
try {
const t = performance.now();
const located = await mdd.locateBytes(path);
const dt = (performance.now() - t).toFixed(0);
if (located.data) {
console.log(
`[MDD-AUDIO] HIT path="${path}" mdd[${i}] ${dt}ms bytes=${located.data.byteLength}`,
);
const blob = new Blob([new Uint8Array(located.data)]);
url = URL.createObjectURL(blob);
trackedUrls.push(url);
el.setAttribute('data-mdd-audio', url);
break outer;
} else {
console.log(`[MDD-AUDIO] miss path="${path}" mdd[${i}] ${dt}ms`);
}
} catch (err) {
console.warn(
`[MDD-AUDIO] error path="${path}" mdd[${i}]:`,
(err as Error).message ?? err,
);
}
}
}
if (!url) {
console.warn(`[MDD-AUDIO] not found for key=${key} after ${candidates.length} probes`);
}
} else {
console.log(`[MDD-AUDIO] cache hit key=${key}`);
}
if (!url) return;
const audio = new Audio(url);
audio.play().catch((err) => {
console.warn('[MDD-AUDIO] playback failed for key', key, err);
});
});
}
}
function wireMdxAnchors(
container: HTMLElement,
mdds: MDDInstance[],
@@ -368,7 +465,15 @@ export const createMdictProvider = ({
for (const name of mddNames) {
try {
const mddFile = await fs.openFile(`${dict.bundleDir}/${name}`, 'Dictionaries');
mddInsts.push(await MDD.create(mddFile, { lazy: true }));
// MDD is eager: js-mdict's lazy binary-search on the per-block
// envelope uses `comp` (localeCompare), which doesn't match the
// MDD's native byte-sorted storage order. That mismatch makes
// resource lookups (`sound.png`, `<KEY>.mp3`, …) miss even when
// the file is present, breaking icons / audio. MDDs are smaller
// than MDXs (a few MB to a few hundred MB for audio packs) and
// the eager init was already fast enough — only the MDX side
// (millions of headwords) actually needed lazy.
mddInsts.push(await MDD.create(mddFile));
} catch (err) {
console.warn('Failed to open MDD resource bundle', name, err);
}
@@ -487,6 +592,12 @@ export const createMdictProvider = ({
);
if (ctx.signal.aborted) return { ok: false, reason: 'error', message: 'aborted' };
wireMdxAnchors(body, mdds, trackedUrls, ctx.onNavigate);
// Some MDicts (notably Vocabulary.com-derived ones) wire audio
// playback through inline `onclick="v0r.v(this,'KEY')"` handlers
// that depend on the dict's own `j.js` script. We never run
// MDX-supplied JS inside the shadow root (XSS surface), so parse
// the audio key ourselves and bind a CSP-safe replacement.
await wireMdictAudioOnclick(body, mdds, trackedUrls);
// Attach a shadow root to a dedicated host so the dict's CSS (loose
// .css files imported alongside + `<link>` references resolved from
@@ -1,5 +1,6 @@
import { transferManager } from '@/services/transferManager';
import { getReplicaAdapter } from './replicaRegistry';
import { isSyncCategoryEnabled } from './syncCategories';
import { getAccessToken } from '@/utils/access';
import type { AppService, BaseDir } from '@/types/system';
import type { ReplicaTransferFile } from '@/store/transferStore';
@@ -43,6 +44,11 @@ interface ReplicaBinaryRecord {
* publishReplicaManifest call (binaries first, manifest last).
*
* No-op when:
* - the user has turned the matching sync category off in Manage Sync
* (`dictionary`, `font`, `texture` — i.e. backgrounds). Skipping
* here keeps potentially large bundle uploads from leaving the
* device when the user has opted out, and matches the metadata-side
* gate already enforced by `publishReplicaUpsert`.
* - the record lacks contentId (legacy / unsynced)
* - the kind isn't registered or has no binary capability
* - TransferManager isn't initialized yet (pre-library mount)
@@ -55,6 +61,7 @@ export const queueReplicaBinaryUpload = async <T extends ReplicaBinaryRecord>(
record: T,
appService: AppService,
): Promise<string | null> => {
if (!isSyncCategoryEnabled(kind)) return null;
if (!record.contentId) return null;
if (!(await getAccessToken())) return null;
if (!transferManager.isReady()) return null;