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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user