Files
readest/apps/readest-app/src/store/customTextureStore.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

491 lines
16 KiB
TypeScript

import { create } from 'zustand';
import { EnvConfigType } from '@/services/environment';
import {
CustomTexture,
PREDEFINED_TEXTURES,
createCustomTexture,
mountBackgroundTexture,
unmountBackgroundTexture,
} from '@/styles/textures';
import { useSettingsStore } from './settingsStore';
import { getReplicaPersistEnv } from '@/services/sync/replicaPersist';
import { publishReplicaDelete, publishReplicaUpsert } from '@/services/sync/replicaPublish';
import { TEXTURE_KIND } from '@/services/sync/adapters/texture';
import { computeTextureContentId } from '@/services/imageService';
import { migrateLegacyReplicas } from '@/services/sync/migrateLegacy';
const publishTextureUpsert = (texture: CustomTexture): void => {
if (!texture.contentId) return;
void publishReplicaUpsert(TEXTURE_KIND, texture, texture.contentId, texture.reincarnation);
};
const publishTextureDelete = (contentId: string): void => {
void publishReplicaDelete(TEXTURE_KIND, contentId);
};
interface TextureStoreState {
textures: CustomTexture[];
loading: boolean;
setTextures: (textures: CustomTexture[]) => void;
addTexture: (
path: string,
options?: Partial<Omit<CustomTexture, 'id' | 'path'>>,
) => CustomTexture;
removeTexture: (id: string) => boolean;
updateTexture: (id: string, updates: Partial<CustomTexture>) => boolean;
getTexture: (id: string) => CustomTexture | undefined;
getAllTextures: () => CustomTexture[];
getAvailableTextures: () => CustomTexture[];
clearAllTextures: () => void;
/** Look up a local texture by its cross-device contentId. */
findByContentId: (contentId: string) => CustomTexture | undefined;
/**
* Add a remote-sourced texture from a replica pull WITHOUT republishing.
* The placeholder lands with `unavailable: true`; the binary download
* handler clears the flag on completion.
*/
applyRemoteTexture: (texture: CustomTexture) => 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;
/**
* Activation path for a remote-pulled texture once its binary has
* landed on disk: clear the `unavailable` flag and load the file
* into a blob URL so the panel can render the swatch and so
* `applyTexture` can mount it without re-reading disk. Mirrors the
* font activation, minus the @font-face injection (textures only
* mount when selected via `applyTexture`).
*/
activateTextureByContentId: (envConfig: EnvConfigType, contentId: string) => Promise<void>;
applyTexture: (envConfig: EnvConfigType, textureId: string) => Promise<void>;
loadTexture: (envConfig: EnvConfigType, textureId: string) => Promise<CustomTexture>;
loadTextures: (envConfig: EnvConfigType, textureIds: string[]) => Promise<CustomTexture[]>;
loadAllTextures: (envConfig: EnvConfigType) => Promise<CustomTexture[]>;
unloadTexture: (textureId: string) => boolean;
unloadAllTextures: () => void;
getLoadedTextures: () => CustomTexture[];
isTextureLoaded: (textureId: string) => boolean;
loadCustomTextures: (envConfig: EnvConfigType) => Promise<void>;
saveCustomTextures: (envConfig: EnvConfigType) => Promise<void>;
}
function toSettingsTexture(texture: CustomTexture): CustomTexture {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { blobUrl, loaded, error, ...settingsTexture } = texture;
return settingsTexture;
}
export const useCustomTextureStore = create<TextureStoreState>((set, get) => ({
textures: [],
loading: false,
setTextures: (textures) => set({ textures }),
addTexture: (path, options) => {
const texture = createCustomTexture(path, options);
const existingTexture = get().textures.find((t) => t.id === texture.id);
if (existingTexture) {
// Re-import of an existing texture. Under CRDT remove-wins, a plain
// upsert can't revive a server-side tombstone — the next pull would
// re-apply the delete and the texture silently disappears while
// logged into cloud sync (same latent bug as 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 =
!!texture.contentId &&
!existingTexture.reincarnation &&
(!!existingTexture.deletedAt || existingTexture.contentId === texture.contentId);
const reincarnation =
texture.reincarnation ??
(shouldMintReincarnation
? Math.random().toString(36).slice(2)
: existingTexture.reincarnation);
get().updateTexture(texture.id, {
...texture,
path: texture.path,
downloadedAt: Date.now(),
deletedAt: undefined,
reincarnation,
loaded: false,
blobUrl: undefined,
error: undefined,
});
set((state) => ({
textures: [...state.textures],
}));
const refreshed = get().getTexture(texture.id) ?? existingTexture;
publishTextureUpsert(refreshed);
return refreshed;
}
const newTexture = {
...texture,
downloadedAt: Date.now(),
};
set((state) => ({
textures: [...state.textures, newTexture],
}));
publishTextureUpsert(newTexture);
return newTexture;
},
removeTexture: (id) => {
const texture = get().getTexture(id);
if (!texture) return false;
if (texture.blobUrl) {
URL.revokeObjectURL(texture.blobUrl);
}
const result = get().updateTexture(id, {
deletedAt: Date.now(),
blobUrl: undefined,
loaded: false,
error: undefined,
});
set((state) => ({
textures: [...state.textures],
}));
if (texture.contentId) publishTextureDelete(texture.contentId);
return result;
},
updateTexture: (id, updates) => {
const state = get();
const textureIndex = state.textures.findIndex((texture) => texture.id === id);
if (textureIndex === -1) return false;
set((state) => ({
textures: state.textures.map((texture, index) =>
index === textureIndex ? { ...texture, ...updates } : texture,
),
}));
return true;
},
findByContentId: (contentId) =>
contentId ? get().textures.find((t) => t.contentId === contentId) : undefined,
applyRemoteTexture: (texture) => {
set((state) => {
const existingIdx = state.textures.findIndex((t) => t.id === texture.id);
const textures =
existingIdx >= 0
? state.textures.map((t, i) =>
i === existingIdx ? { ...texture, deletedAt: undefined } : t,
)
: [...state.textures, texture];
return { textures };
});
const env = getReplicaPersistEnv();
if (env) void get().saveCustomTextures(env);
},
softDeleteByContentId: (contentId) => {
const target = get().textures.find((t) => t.contentId === contentId && !t.deletedAt);
if (!target) return;
set((state) => ({
textures: state.textures.map((t) =>
t.id === target.id ? { ...t, deletedAt: Date.now(), blobUrl: undefined, loaded: false } : t,
),
}));
if (target.blobUrl) URL.revokeObjectURL(target.blobUrl);
const env = getReplicaPersistEnv();
if (env) void get().saveCustomTextures(env);
},
markAvailableByContentId: (contentId) => {
set((state) => ({
textures: state.textures.map((t) =>
t.contentId === contentId ? { ...t, unavailable: undefined } : t,
),
}));
const env = getReplicaPersistEnv();
if (env) void get().saveCustomTextures(env);
},
activateTextureByContentId: async (envConfig, contentId) => {
get().markAvailableByContentId(contentId);
const target = get().textures.find((t) => t.contentId === contentId && !t.deletedAt);
if (!target) return;
try {
await get().loadTexture(envConfig, target.id);
const env = getReplicaPersistEnv();
if (env) await get().saveCustomTextures(env);
} catch (err) {
console.warn('activateTextureByContentId failed', contentId, err);
}
},
getTexture: (id) => {
return get().textures.find((texture) => texture.id === id);
},
getAllTextures: () => {
return get().textures;
},
getAvailableTextures: () => {
return get().textures.filter((texture) => !texture.deletedAt);
},
clearAllTextures: () => {
const { textures } = get();
textures.forEach((texture) => {
if (texture.blobUrl) {
URL.revokeObjectURL(texture.blobUrl);
}
});
set({ textures: [] });
},
loadTexture: async (envConfig, textureId) => {
const texture = get().getTexture(textureId);
if (!texture) {
throw new Error(`Texture with id "${textureId}" not found`);
}
if (texture.deletedAt) {
throw new Error(`Texture "${texture.name}" has been deleted`);
}
if (texture.loaded && texture.blobUrl && !texture.error) {
return texture;
}
try {
get().updateTexture(textureId, {
loaded: false,
error: undefined,
});
const appService = await envConfig.getAppService();
const textureFile = await appService.openFile(texture.path, 'Images');
const ext = texture.path.split('.').pop()?.toLowerCase();
const mimeTypes: { [key: string]: string } = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
};
const mimeType = mimeTypes[ext || ''] || 'image/jpeg';
const blob = new Blob([await textureFile.arrayBuffer()], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
get().updateTexture(textureId, {
blobUrl,
loaded: true,
error: undefined,
});
const updatedTexture = get().getTexture(textureId)!;
return updatedTexture;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
get().updateTexture(textureId, {
loaded: false,
error: errorMessage,
blobUrl: undefined,
});
throw error;
}
},
loadTextures: async (envConfig, textureIds) => {
set({ loading: true });
try {
const results = await Promise.allSettled(
textureIds.map((id) => get().loadTexture(envConfig, id)),
);
return results
.filter(
(result): result is PromiseFulfilledResult<CustomTexture> =>
result.status === 'fulfilled',
)
.map((result) => result.value);
} finally {
set({ loading: false });
}
},
loadAllTextures: async (envConfig) => {
const textureIds = get()
.getAvailableTextures()
.map((texture) => texture.id);
return await get().loadTextures(envConfig, textureIds);
},
unloadTexture: (textureId) => {
const texture = get().getTexture(textureId);
if (texture?.blobUrl) {
URL.revokeObjectURL(texture.blobUrl);
}
return get().updateTexture(textureId, {
blobUrl: undefined,
loaded: false,
error: undefined,
});
},
unloadAllTextures: () => {
const textures = get().getAllTextures();
textures.forEach((texture) => {
if (texture.blobUrl) {
URL.revokeObjectURL(texture.blobUrl);
}
});
textures.forEach((texture) => {
get().updateTexture(texture.id, {
blobUrl: undefined,
loaded: false,
error: undefined,
});
});
},
getLoadedTextures: () => {
return get()
.getAvailableTextures()
.filter((texture) => texture.loaded && !texture.error);
},
isTextureLoaded: (textureId) => {
const texture = get().getTexture(textureId);
return texture?.loaded === true && !texture.error && !texture.deletedAt;
},
applyTexture: async (envConfig, textureId) => {
const customTextures = get().getAvailableTextures();
const allTextures = [...PREDEFINED_TEXTURES, ...customTextures];
let selectedTexture = allTextures.find((t) => t.id === textureId);
if (!selectedTexture || selectedTexture.id === 'none') {
unmountBackgroundTexture(document);
return;
}
if (customTextures.find((t) => t.id === textureId) && !get().isTextureLoaded(textureId)) {
selectedTexture = await get().loadTexture(envConfig, textureId);
}
mountBackgroundTexture(document, selectedTexture);
},
loadCustomTextures: async (envConfig) => {
try {
const { settings } = useSettingsStore.getState();
const currentTextures = get().textures;
if (settings?.customTextures) {
const textures = settings.customTextures.map((texture) => {
const existingTexture = currentTextures.find((t) => t.id === texture.id);
return {
...texture,
loaded: existingTexture?.loaded || false,
error: existingTexture?.error,
blobUrl: existingTexture?.blobUrl,
};
});
set({ textures });
await get().loadAllTextures(envConfig);
}
} catch (error) {
console.error('Failed to load custom textures settings:', error);
}
},
saveCustomTextures: async (envConfig) => {
try {
const { settings, setSettings, saveSettings } = useSettingsStore.getState();
const { textures } = get();
settings.customTextures = textures.map(toSettingsTexture);
setSettings(settings);
saveSettings(envConfig, settings);
} catch (error) {
console.error('Failed to save custom textures settings:', error);
throw error;
}
},
}));
/**
* Look up a texture by its cross-device contentId, falling back to the
* persisted `settings.customTextures` when the in-memory store is empty.
* The pull-side orchestrator runs at app boot — earlier than the color
* panel mount, so loadCustomTextures hasn't hydrated the zustand store
* yet. Without the fallback every refresh would mint a fresh bundleDir
* per row and re-download.
*/
export const findTextureByContentId = (contentId: string): CustomTexture | undefined => {
if (!contentId) return undefined;
const inMemory = useCustomTextureStore.getState().findByContentId(contentId);
if (inMemory) return inMemory;
const persisted = useSettingsStore.getState().settings?.customTextures ?? [];
return persisted.find((t) => t.contentId === contentId && !t.deletedAt);
};
/**
* One-time migration: rehash legacy flat-path textures (imported before
* replica sync shipped) into the per-bundle layout so they sync
* across devices without forcing the user to re-import. Idempotent;
* skips textures that already carry `contentId`. Implementation lives
* in `migrateLegacyReplicas` — shared with custom fonts.
*/
export const migrateLegacyTextures = (envConfig: EnvConfigType): Promise<void> =>
migrateLegacyReplicas<CustomTexture>(envConfig, {
kind: TEXTURE_KIND,
baseDir: 'Images',
getCandidates: () =>
useCustomTextureStore
.getState()
.textures.filter(
(t) => !t.contentId && !t.bundleDir && !t.deletedAt && !t.path.includes('/'),
),
computeContentId: computeTextureContentId,
updateRecord: (id, next) => useCustomTextureStore.getState().updateTexture(id, next),
saveStore: (env) => useCustomTextureStore.getState().saveCustomTextures(env),
publishUpsert: publishTextureUpsert,
});
// Cleanup blob URLs before page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
const store = useCustomTextureStore.getState();
const textures = store.getAllTextures();
textures.forEach((texture) => {
if (texture.blobUrl) {
URL.revokeObjectURL(texture.blobUrl);
}
});
});
}