Files
readest/apps/readest-app/src/store/customFontStore.ts
T
Huang Xin 274afb0677 fix(sync): mint reincarnation token on re-import of custom fonts/textures (#4410) (#4416)
Custom fonts (and textures) disappeared a few seconds after opening a book
when logged into cloud sync. Under the replica layer's CRDT remove-wins
semantics, deleting a font writes a server-side tombstone that a plain
field upsert cannot revive — only a reincarnation token whose HLC beats
the tombstone does. Re-uploading the same file (same contentId) cleared
`deletedAt` locally but published the upsert with no token, so the tombstone
survived and the next pull (boot/periodic/book-open/visibility) re-applied
the soft-delete via softDeleteByContentId, making the font vanish. Logging
out stopped the pull, which is why it only reproduced while signed in.

addFont/addTexture now mint a reincarnation token when re-adding an existing
entry that is either soft-deleted or still-live with the same contentId (and
has no token yet), preserving any existing token. This mirrors the
dictionary's reincarnation handling (dictionaryService) and OPDS's token
style, fixing both the local re-import-after-delete case and the multi-device
stale-local race. The token is inert when there is no tombstone, so live
re-imports remain safe.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:56:38 +02:00

470 lines
15 KiB
TypeScript

import { create } from 'zustand';
import { EnvConfigType } from '@/services/environment';
import {
CustomFont,
createCustomFont,
getFontFormat,
getMimeType,
mountCustomFont,
} from '@/styles/fonts';
import { useSettingsStore } from './settingsStore';
import { getReplicaPersistEnv } from '@/services/sync/replicaPersist';
import { publishReplicaDelete, publishReplicaUpsert } from '@/services/sync/replicaPublish';
import { FONT_KIND } from '@/services/sync/adapters/font';
import { computeFontContentId } from '@/services/fontService';
import { migrateLegacyReplicas } from '@/services/sync/migrateLegacy';
const publishFontUpsert = (font: CustomFont): void => {
if (!font.contentId) return;
void publishReplicaUpsert(FONT_KIND, font, font.contentId, font.reincarnation);
};
const publishFontDelete = (contentId: string): void => {
void publishReplicaDelete(FONT_KIND, contentId);
};
interface FontStoreState {
fonts: CustomFont[];
loading: boolean;
setFonts: (fonts: CustomFont[]) => void;
addFont: (path: string, options?: Partial<Omit<CustomFont, 'id' | 'path'>>) => CustomFont;
removeFont: (id: string) => boolean;
updateFont: (id: string, updates: Partial<CustomFont>) => boolean;
getFont: (id: string) => CustomFont | undefined;
getAllFonts: () => CustomFont[];
getAvailableFonts: () => CustomFont[];
clearAllFonts: () => void;
/** Look up a local font by its cross-device contentId. */
findByContentId: (contentId: string) => CustomFont | undefined;
/**
* Add a remote-sourced font from a replica pull WITHOUT republishing.
* The placeholder lands with `unavailable: true`; the binary download
* handler clears the flag on completion.
*/
applyRemoteFont: (font: CustomFont) => void;
/** Soft-delete by contentId, skipping the publish call. */
softDeleteByContentId: (contentId: string) => void;
/** Clear the placeholder unavailable flag once binaries land on disk. */
markAvailableByContentId: (contentId: string) => void;
/**
* Full activation path for a remote-pulled font once its binary has
* landed on disk: clear the `unavailable` flag, load the file into a
* blob URL, and inject the `@font-face` rule so the family is
* actually rendered. Manual imports get this for free in
* `CustomFonts.tsx`; auto-download needs the same plumbing or the
* font appears in the UI but renders in a fallback face.
*/
activateFontByContentId: (envConfig: EnvConfigType, contentId: string) => Promise<void>;
loadFont: (envConfig: EnvConfigType, fontId: string) => Promise<CustomFont>;
loadFonts: (envConfig: EnvConfigType, fontIds: string[]) => Promise<CustomFont[]>;
loadAllFonts: (envConfig: EnvConfigType) => Promise<CustomFont[]>;
unloadFont: (fontId: string) => boolean;
unloadAllFonts: () => void;
getFontFamilies: () => string[];
getLoadedFonts: () => CustomFont[];
isFontLoaded: (fontId: string) => boolean;
loadCustomFonts: (envConfig: EnvConfigType) => Promise<void>;
saveCustomFonts: (envConfig: EnvConfigType) => Promise<void>;
}
function toSettingsFont(font: CustomFont): CustomFont {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { blobUrl, loaded, error, ...settingsFont } = font;
return settingsFont;
}
export const useCustomFontStore = create<FontStoreState>((set, get) => ({
fonts: [],
loading: false,
setFonts: (fonts) => set({ fonts }),
addFont: (path, options) => {
const font = createCustomFont(path, options);
const existingFont = get().fonts.find((f) => f.id === font.id);
if (existingFont) {
// Re-import of an existing font. Under CRDT remove-wins, a plain
// upsert can't revive a server-side tombstone — the next pull would
// re-apply the delete and the font silently disappears while logged
// into cloud sync (issue #4410). Mint a reincarnation token so the
// row surfaces as alive on every device's next pull. Covers both
// re-import after a local delete and re-import while the entry is
// still live but another device tombstoned the row (the token is
// inert when there's no tombstone). Preserve any existing token
// instead of churning a new one on each import. Mirrors
// dictionaryService's reincarnation logic.
const shouldMintReincarnation =
!!font.contentId &&
!existingFont.reincarnation &&
(!!existingFont.deletedAt || existingFont.contentId === font.contentId);
const reincarnation =
font.reincarnation ??
(shouldMintReincarnation
? Math.random().toString(36).slice(2)
: existingFont.reincarnation);
get().updateFont(font.id, {
...font,
path: font.path,
downloadedAt: Date.now(),
deletedAt: undefined,
reincarnation,
loaded: false,
blobUrl: undefined,
error: undefined,
});
set((state) => ({
fonts: [...state.fonts],
}));
const refreshed = get().getFont(font.id) ?? existingFont;
publishFontUpsert(refreshed);
return refreshed;
}
const newFont = {
...font,
downloadedAt: Date.now(),
};
set((state) => ({
fonts: [...state.fonts, newFont],
}));
publishFontUpsert(newFont);
return newFont;
},
removeFont: (id) => {
const font = get().getFont(id);
if (!font) return false;
if (font.blobUrl) {
URL.revokeObjectURL(font.blobUrl);
}
const result = get().updateFont(id, {
deletedAt: Date.now(),
blobUrl: undefined,
loaded: false,
error: undefined,
});
set((state) => ({
fonts: [...state.fonts],
}));
if (font.contentId) publishFontDelete(font.contentId);
return result;
},
updateFont: (id, updates) => {
const state = get();
const fontIndex = state.fonts.findIndex((font) => font.id === id);
if (fontIndex === -1) return false;
set((state) => ({
fonts: state.fonts.map((font, index) =>
index === fontIndex ? { ...font, ...updates } : font,
),
}));
return true;
},
findByContentId: (contentId) =>
contentId ? get().fonts.find((f) => f.contentId === contentId) : undefined,
applyRemoteFont: (font) => {
set((state) => {
const existingIdx = state.fonts.findIndex((f) => f.id === font.id);
const fonts =
existingIdx >= 0
? state.fonts.map((f, i) => (i === existingIdx ? { ...font, deletedAt: undefined } : f))
: [...state.fonts, font];
return { fonts };
});
const env = getReplicaPersistEnv();
if (env) void get().saveCustomFonts(env);
},
softDeleteByContentId: (contentId) => {
const target = get().fonts.find((f) => f.contentId === contentId && !f.deletedAt);
if (!target) return;
set((state) => ({
fonts: state.fonts.map((f) =>
f.id === target.id ? { ...f, deletedAt: Date.now(), blobUrl: undefined, loaded: false } : f,
),
}));
if (target.blobUrl) URL.revokeObjectURL(target.blobUrl);
const env = getReplicaPersistEnv();
if (env) void get().saveCustomFonts(env);
},
markAvailableByContentId: (contentId) => {
set((state) => ({
fonts: state.fonts.map((f) =>
f.contentId === contentId ? { ...f, unavailable: undefined } : f,
),
}));
const env = getReplicaPersistEnv();
if (env) void get().saveCustomFonts(env);
},
activateFontByContentId: async (envConfig, contentId) => {
get().markAvailableByContentId(contentId);
const target = get().fonts.find((f) => f.contentId === contentId && !f.deletedAt);
if (!target) return;
try {
const loaded = await get().loadFont(envConfig, target.id);
if (typeof document !== 'undefined') {
mountCustomFont(document, loaded);
}
const env = getReplicaPersistEnv();
if (env) await get().saveCustomFonts(env);
} catch (err) {
console.warn('activateFontByContentId failed', contentId, err);
}
},
getFont: (id) => {
return get().fonts.find((font) => font.id === id);
},
getAllFonts: () => {
return get().fonts;
},
getAvailableFonts: () => {
return get().fonts.filter((font) => !font.deletedAt);
},
clearAllFonts: () => {
const { fonts } = get();
fonts.forEach((font) => {
if (font.blobUrl) {
URL.revokeObjectURL(font.blobUrl);
}
});
set({ fonts: [] });
},
loadFont: async (envConfig, fontId) => {
const font = get().getFont(fontId);
if (!font) {
throw new Error(`Font with id "${fontId}" not found`);
}
if (font.deletedAt) {
throw new Error(`Font "${font.name}" has been deleted`);
}
if (font.loaded && font.blobUrl && !font.error) {
return font;
}
try {
get().updateFont(fontId, {
loaded: false,
error: undefined,
});
const appService = await envConfig.getAppService();
const fontFile = await appService.openFile(font.path, 'Fonts');
const format = getFontFormat(font.path);
const mimeType = getMimeType(format);
const blob = new Blob([await fontFile.arrayBuffer()], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
get().updateFont(fontId, {
blobUrl,
loaded: true,
error: undefined,
});
const updatedFont = get().getFont(fontId)!;
return updatedFont;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
get().updateFont(fontId, {
loaded: false,
error: errorMessage,
blobUrl: undefined,
});
throw error;
}
},
loadFonts: async (envConfig, fontIds) => {
set({ loading: true });
try {
const results = await Promise.allSettled(fontIds.map((id) => get().loadFont(envConfig, id)));
return results
.filter(
(result): result is PromiseFulfilledResult<CustomFont> => result.status === 'fulfilled',
)
.map((result) => result.value);
} finally {
set({ loading: false });
}
},
loadAllFonts: async (envConfig) => {
const fontIds = get()
.getAvailableFonts()
.map((font) => font.id);
return await get().loadFonts(envConfig, fontIds);
},
unloadFont: (fontId) => {
const font = get().getFont(fontId);
if (font?.blobUrl) {
URL.revokeObjectURL(font.blobUrl);
}
return get().updateFont(fontId, {
blobUrl: undefined,
loaded: false,
error: undefined,
});
},
unloadAllFonts: () => {
const fonts = get().getAllFonts();
fonts.forEach((font) => {
if (font.blobUrl) {
URL.revokeObjectURL(font.blobUrl);
}
});
fonts.forEach((font) => {
get().updateFont(font.id, {
blobUrl: undefined,
loaded: false,
error: undefined,
});
});
},
getFontFamilies: () => {
return get()
.getAvailableFonts()
.filter((font) => font.loaded && !font.error)
.map((font) => font.family || font.name)
.filter((value, index, self) => self.indexOf(value) === index)
.sort((a, b) => a.localeCompare(b));
},
getLoadedFonts: () => {
return get()
.getAvailableFonts()
.filter((font) => font.loaded && !font.error);
},
isFontLoaded: (fontId) => {
const font = get().getFont(fontId);
return font?.loaded === true && !font.error && !font.deletedAt;
},
loadCustomFonts: async (envConfig) => {
try {
const { settings } = useSettingsStore.getState();
const currentFonts = get().fonts;
if (settings?.customFonts) {
const fonts = settings.customFonts.map((font) => {
const existingFont = currentFonts.find((f) => f.id === font.id);
return {
...font,
loaded: existingFont?.loaded || false,
error: existingFont?.error,
blobUrl: existingFont?.blobUrl,
};
});
set({ fonts });
await get().loadAllFonts(envConfig);
// Mount @font-face on the main document so settings UI / library
// chrome can render in the actual face. The Reader mounts again
// into book documents on top of this; mountCustomFont keys by
// font id so the second call is an idempotent update.
if (typeof document !== 'undefined') {
for (const font of get().getLoadedFonts()) {
mountCustomFont(document, font);
}
}
}
} catch (error) {
console.error('Failed to load custom fonts settings:', error);
}
},
saveCustomFonts: async (envConfig) => {
try {
const { settings, setSettings, saveSettings } = useSettingsStore.getState();
const { fonts } = get();
settings.customFonts = fonts.map(toSettingsFont);
setSettings(settings);
saveSettings(envConfig, settings);
} catch (error) {
console.error('Failed to save custom fonts settings:', error);
throw error;
}
},
}));
/**
* Look up a font by its cross-device contentId, falling back to the
* persisted `settings.customFonts` when the in-memory store is empty.
* The pull-side orchestrator runs at app boot — earlier than the font
* panel mount, so loadCustomFonts hasn't hydrated the zustand store
* yet. Without the fallback every refresh would mint a fresh bundleDir
* per row and re-download.
*/
export const findFontByContentId = (contentId: string): CustomFont | undefined => {
if (!contentId) return undefined;
const inMemory = useCustomFontStore.getState().findByContentId(contentId);
if (inMemory) return inMemory;
const persisted = useSettingsStore.getState().settings?.customFonts ?? [];
return persisted.find((f) => f.contentId === contentId && !f.deletedAt);
};
/**
* One-time migration: rehash legacy flat-path fonts (imported before
* replica sync shipped) into the per-bundle layout so they sync
* across devices without forcing the user to re-import. Idempotent;
* skips fonts that already carry `contentId`. Implementation lives in
* `migrateLegacyReplicas` — shared with custom textures.
*/
export const migrateLegacyFonts = (envConfig: EnvConfigType): Promise<void> =>
migrateLegacyReplicas<CustomFont>(envConfig, {
kind: FONT_KIND,
baseDir: 'Fonts',
getCandidates: () =>
useCustomFontStore
.getState()
.fonts.filter((f) => !f.contentId && !f.bundleDir && !f.deletedAt && !f.path.includes('/')),
computeContentId: computeFontContentId,
updateRecord: (id, next) => useCustomFontStore.getState().updateFont(id, next),
saveStore: (env) => useCustomFontStore.getState().saveCustomFonts(env),
publishUpsert: publishFontUpsert,
});
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
const store = useCustomFontStore.getState();
const fonts = store.getAllFonts();
fonts.forEach((font) => {
if (font.blobUrl) {
URL.revokeObjectURL(font.blobUrl);
}
});
});
}