diff --git a/apps/readest-app/src/__tests__/libs/replicaSchemas.test.ts b/apps/readest-app/src/__tests__/libs/replicaSchemas.test.ts index 114e87ce..fcf1960c 100644 --- a/apps/readest-app/src/__tests__/libs/replicaSchemas.test.ts +++ b/apps/readest-app/src/__tests__/libs/replicaSchemas.test.ts @@ -28,10 +28,10 @@ const baseRow = (overrides: Partial = {}): ReplicaRow => ({ }); describe('isAllowedKind', () => { - test('current allowlist contains dictionary + font', () => { + test('current allowlist contains dictionary + font + texture', () => { expect(isAllowedKind('dictionary')).toBe(true); expect(isAllowedKind('font')).toBe(true); - expect(isAllowedKind('texture')).toBe(false); + expect(isAllowedKind('texture')).toBe(true); expect(isAllowedKind('opds_catalog')).toBe(false); }); diff --git a/apps/readest-app/src/__tests__/libs/replicaSyncServer.test.ts b/apps/readest-app/src/__tests__/libs/replicaSyncServer.test.ts index 14264683..d69f5486 100644 --- a/apps/readest-app/src/__tests__/libs/replicaSyncServer.test.ts +++ b/apps/readest-app/src/__tests__/libs/replicaSyncServer.test.ts @@ -145,7 +145,7 @@ describe('validatePullParams', () => { }); test('rejects unknown kind', () => { - const result = validatePullParams('texture', null); + const result = validatePullParams('opds_catalog', null); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(422); diff --git a/apps/readest-app/src/__tests__/services/sync/adapters/texture.test.ts b/apps/readest-app/src/__tests__/services/sync/adapters/texture.test.ts new file mode 100644 index 00000000..bc155dca --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/adapters/texture.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'vitest'; +import { computeTextureContentId, textureAdapter } from '@/services/sync/adapters/texture'; +import { hlcPack } from '@/libs/crdt'; +import type { CustomTexture } from '@/styles/textures'; +import type { Hlc, ReplicaRow } from '@/types/replica'; + +const NOW = 1_700_000_000_000; +const DEV = 'dev-a'; +const HLC = hlcPack(NOW, 0, DEV) as Hlc; + +const baseTexture = (overrides: Partial = {}): CustomTexture => ({ + id: 'placeholder', + name: 'Marble Texture', + path: 'bundle-1/marble.jpg', + bundleDir: 'bundle-1', + contentId: 'content-hash-tex-1', + byteSize: 51200, + downloadedAt: NOW, + ...overrides, +}); + +describe('textureAdapter contract', () => { + test('kind is "texture"', () => { + expect(textureAdapter.kind).toBe('texture'); + }); + + test('schemaVersion is 1', () => { + expect(textureAdapter.schemaVersion).toBe(1); + }); + + test('binary capability uses BaseDir "Images"', () => { + expect(textureAdapter.binary?.localBaseDir).toBe('Images'); + }); + + test('computeId returns the contentId when set', async () => { + const t = baseTexture({ contentId: 'content-hash-xyz' }); + expect(await textureAdapter.computeId(t)).toBe('content-hash-xyz'); + }); +}); + +describe('pack ∘ unpack = identity for the synced subset', () => { + test('synced fields round-trip', () => { + const t = baseTexture({ + name: 'Oak Wood', + byteSize: 99999, + downloadedAt: NOW, + }); + const packed = textureAdapter.pack(t); + const unpacked = textureAdapter.unpack(packed); + expect(unpacked.name).toBe('Oak Wood'); + expect(unpacked.byteSize).toBe(99999); + expect(unpacked.downloadedAt).toBe(NOW); + }); + + test('per-device fields (path, bundleDir) are NOT in the synced fields object', () => { + const t = baseTexture({ bundleDir: 'device-local-uniqueId-123', path: 'bundleDir/marble.jpg' }); + const packed = textureAdapter.pack(t); + expect(packed['bundleDir']).toBeUndefined(); + expect(packed['path']).toBeUndefined(); + }); + + test('unavailable / deletedAt are NOT synced (tombstones handle delete)', () => { + const t = baseTexture({ unavailable: true, deletedAt: 999 }); + const packed = textureAdapter.pack(t); + expect(packed['unavailable']).toBeUndefined(); + expect(packed['deletedAt']).toBeUndefined(); + }); +}); + +describe('unpackRow', () => { + const baseRow = (overrides: Partial = {}): ReplicaRow => ({ + user_id: 'u1', + kind: 'texture', + replica_id: 'content-hash-tex-1', + fields_jsonb: { + name: { v: 'Marble', t: HLC, s: DEV }, + byteSize: { v: 51200, t: HLC, s: DEV }, + }, + manifest_jsonb: { + files: [{ filename: 'marble.jpg', byteSize: 51200, partialMd5: 'x' }], + schemaVersion: 1, + }, + deleted_at_ts: null, + reincarnation: null, + updated_at_ts: HLC, + schema_version: 1, + ...overrides, + }); + + test('builds a placeholder texture with bundleDir + path + unavailable=true', () => { + const texture = textureAdapter.unpackRow(baseRow(), 'fresh-bundle'); + expect(texture).not.toBe(null); + expect(texture!.id).toBe('fresh-bundle'); + expect(texture!.bundleDir).toBe('fresh-bundle'); + expect(texture!.contentId).toBe('content-hash-tex-1'); + expect(texture!.path).toBe('fresh-bundle/marble.jpg'); + expect(texture!.unavailable).toBe(true); + expect(texture!.name).toBe('Marble'); + expect(texture!.byteSize).toBe(51200); + }); + + test('returns null when name is missing', () => { + const row = baseRow(); + delete (row.fields_jsonb as Record)['name']; + expect(textureAdapter.unpackRow(row, 'b')).toBe(null); + }); + + test('returns null when manifest is absent (no filename to construct path)', () => { + expect(textureAdapter.unpackRow(baseRow({ manifest_jsonb: null }), 'b')).toBe(null); + }); + + test('reincarnation token propagates', () => { + const texture = textureAdapter.unpackRow(baseRow({ reincarnation: 'epoch-1' }), 'b'); + expect(texture?.reincarnation).toBe('epoch-1'); + }); +}); + +describe('binary.enumerateFiles', () => { + test('returns one entry with logical filename + lfp + byteSize', () => { + const t = baseTexture({ path: 'b1/marble.jpg', byteSize: 51200 }); + const out = textureAdapter.binary!.enumerateFiles(t); + expect(out).toEqual([{ logical: 'marble.jpg', lfp: 'b1/marble.jpg', byteSize: 51200 }]); + }); + + test('handles legacy flat-path textures (no slash)', () => { + const t = baseTexture({ path: 'paper.png', bundleDir: undefined, byteSize: 30000 }); + const out = textureAdapter.binary!.enumerateFiles(t); + expect(out).toEqual([{ logical: 'paper.png', lfp: 'paper.png', byteSize: 30000 }]); + }); +}); + +describe('computeTextureContentId', () => { + test('deterministic over (partialMd5, byteSize, filename)', () => { + const a = computeTextureContentId('abc123', 1024, 'marble.jpg'); + const b = computeTextureContentId('abc123', 1024, 'marble.jpg'); + expect(a).toBe(b); + }); + + test('different partialMd5 → different id', () => { + expect(computeTextureContentId('abc', 1024, 'a.jpg')).not.toBe( + computeTextureContentId('def', 1024, 'a.jpg'), + ); + }); + + test('different byteSize → different id', () => { + expect(computeTextureContentId('abc', 1024, 'a.jpg')).not.toBe( + computeTextureContentId('abc', 2048, 'a.jpg'), + ); + }); + + test('different filename → different id', () => { + expect(computeTextureContentId('abc', 1024, 'a.jpg')).not.toBe( + computeTextureContentId('abc', 1024, 'b.jpg'), + ); + }); + + test('returns 32-hex md5', () => { + expect(computeTextureContentId('abc', 1024, 'a.jpg')).toMatch(/^[0-9a-f]{32}$/); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts index 87302197..6f45916b 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts @@ -8,6 +8,10 @@ vi.mock('@/store/customFontStore', () => ({ useCustomFontStore: { getState: () => ({ markAvailableByContentId: vi.fn() }) }, })); +vi.mock('@/store/customTextureStore', () => ({ + useCustomTextureStore: { getState: () => ({ markAvailableByContentId: vi.fn() }) }, +})); + import { __resetBootstrapForTests, bootstrapReplicaAdapters, @@ -35,12 +39,12 @@ describe('bootstrapReplicaAdapters', () => { test('is idempotent: calling twice is a no-op (does not throw)', () => { bootstrapReplicaAdapters(); bootstrapReplicaAdapters(); - expect(listReplicaAdapters()).toHaveLength(2); + expect(listReplicaAdapters()).toHaveLength(3); }); - test('registers the current allowlist (dictionary, font)', () => { + test('registers the current allowlist (dictionary, font, texture)', () => { bootstrapReplicaAdapters(); const kinds = listReplicaAdapters().map((a) => a.kind); - expect(kinds).toEqual(['dictionary', 'font']); + expect(kinds).toEqual(['dictionary', 'font', 'texture']); }); }); diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 7e123246..be1b1383 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -116,10 +116,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore(); const { isTransferQueueOpen } = useTransferStore(); - // Library page pulls user replicas (dictionaries, custom fonts). - // Deferred 10s; module-scoped dedup means a later navigation to the - // reader won't re-pull the same kind. - useReplicaPull({ kinds: ['dictionary', 'font'] }); + // Library page pulls user replicas (dictionaries, custom fonts, + // background textures). Deferred 10s; module-scoped dedup means a + // later navigation to the reader won't re-pull the same kind. + useReplicaPull({ kinds: ['dictionary', 'font', 'texture'] }); const [showCatalogManager, setShowCatalogManager] = useState( searchParams?.get('opds') === 'true', ); diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index 0cb54fe7..796ff895 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -74,12 +74,12 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => { useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' }); useScreenWakeLock(settings.screenWakeLock); useTransferQueue(libraryLoaded, 5000); - // Reader needs dictionaries for word-lookup and fonts for rendering. - // Mounted here (not in the app-router page wrapper) so the web pages- - // router entry at `pages/reader/[ids].tsx` also gets the pull. - // Module-scoped dedup means navigating between library and reader - // doesn't re-pull. - useReplicaPull({ kinds: ['dictionary', 'font'] }); + // Reader needs dictionaries for word-lookup, fonts for rendering, and + // textures for the page background. Mounted here (not in the app- + // router page wrapper) so the web pages-router entry at + // `pages/reader/[ids].tsx` also gets the pull. Module-scoped dedup + // means navigating between library and reader doesn't re-pull. + useReplicaPull({ kinds: ['dictionary', 'font', 'texture'] }); useEffect(() => { mountAdditionalFonts(document); diff --git a/apps/readest-app/src/components/settings/ColorPanel.tsx b/apps/readest-app/src/components/settings/ColorPanel.tsx index 00988fe6..d1c8db7f 100644 --- a/apps/readest-app/src/components/settings/ColorPanel.tsx +++ b/apps/readest-app/src/components/settings/ColorPanel.tsx @@ -14,6 +14,7 @@ import { useTranslation } from '@/hooks/useTranslation'; import { useSettingsStore } from '@/store/settingsStore'; import { useResetViewSettings } from '@/hooks/useResetSettings'; import { useCustomTextureStore } from '@/store/customTextureStore'; +import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload'; import { saveViewSettings } from '@/helpers/settings'; import { manageSyntaxHighlighting } from '@/utils/highlightjs'; import { SettingsPanelPanelProp } from './SettingsDialog'; @@ -249,10 +250,15 @@ const ColorPanel: React.FC = ({ bookKey, onRegisterReset const textureInfo = await appService?.importImage(selectedFile.path || selectedFile.file); if (!textureInfo) continue; - const customTexture = addTexture(textureInfo.path); - console.log('Added custom texture:', customTexture); + const customTexture = addTexture(textureInfo.path, { + name: textureInfo.name, + contentId: textureInfo.contentId, + bundleDir: textureInfo.bundleDir, + byteSize: textureInfo.byteSize, + }); if (customTexture && !customTexture.error) { await loadTexture(envConfig, customTexture.id); + if (appService) void queueReplicaBinaryUpload('texture', customTexture, appService); } } saveCustomTextures(envConfig); diff --git a/apps/readest-app/src/hooks/useBackgroundTexture.ts b/apps/readest-app/src/hooks/useBackgroundTexture.ts index 85f1e3e2..c7e70111 100644 --- a/apps/readest-app/src/hooks/useBackgroundTexture.ts +++ b/apps/readest-app/src/hooks/useBackgroundTexture.ts @@ -19,7 +19,17 @@ export const useBackgroundTexture = () => { const customTexture = settings.customTextures?.find((t) => t.id === textureId); if (customTexture) { - useCustomTextureStore.getState().addTexture(customTexture.path); + // Carry replica-sync metadata (contentId / bundleDir / byteSize) + // through addTexture so the boot-time "ensure selected texture + // is in the store" path doesn't drop them and silently un- + // publish a remote-pulled record. + useCustomTextureStore.getState().addTexture(customTexture.path, { + name: customTexture.name, + contentId: customTexture.contentId, + bundleDir: customTexture.bundleDir, + byteSize: customTexture.byteSize, + animated: customTexture.animated, + }); } useCustomTextureStore.getState().applyTexture(envConfig, textureId); diff --git a/apps/readest-app/src/hooks/useReplicaPull.ts b/apps/readest-app/src/hooks/useReplicaPull.ts index 144d7900..7c80fe19 100644 --- a/apps/readest-app/src/hooks/useReplicaPull.ts +++ b/apps/readest-app/src/hooks/useReplicaPull.ts @@ -6,10 +6,16 @@ import { findFontByContentId, migrateLegacyFonts, } from '@/store/customFontStore'; +import { + useCustomTextureStore, + findTextureByContentId, + migrateLegacyTextures, +} from '@/store/customTextureStore'; import { transferManager } from '@/services/transferManager'; import { getReplicaSync, subscribeReplicaSyncReady } from '@/services/sync/replicaSync'; import { dictionaryAdapter } from '@/services/sync/adapters/dictionary'; import { fontAdapter } from '@/services/sync/adapters/font'; +import { textureAdapter } from '@/services/sync/adapters/texture'; import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload'; import { replicaPullAndApply, type PullAndApplyDeps } from '@/services/sync/replicaPullAndApply'; import { getAccessToken } from '@/utils/access'; @@ -19,8 +25,9 @@ import type { AppService } from '@/types/system'; import type { ReplicaSyncManager } from '@/services/sync/replicaSyncManager'; import type { ImportedDictionary } from '@/services/dictionaries/types'; import type { CustomFont } from '@/styles/fonts'; +import type { CustomTexture } from '@/styles/textures'; -export type ReplicaKind = 'dictionary' | 'font'; +export type ReplicaKind = 'dictionary' | 'font' | 'texture'; export interface UseReplicaPullOpts { /** Replica kinds this page wants pulled. */ @@ -115,6 +122,42 @@ const buildFontPullDeps = ( isAuthenticated: async () => !!(await getAccessToken()), }); +const buildTexturePullDeps = ( + manager: ReplicaSyncManager, + service: AppService, + envConfig: EnvConfigType, +): PullAndApplyDeps => ({ + adapter: textureAdapter, + pull: () => manager.pull('texture', { since: null }), + findByContentId: (id: string) => findTextureByContentId(id), + hydrateLocalStore: async () => { + await useCustomTextureStore.getState().loadCustomTextures(envConfig); + // Rehash legacy flat-path textures so the user doesn't have to + // re-import them by hand to get them onto other devices. + await migrateLegacyTextures(envConfig); + }, + applyRemote: (texture) => useCustomTextureStore.getState().applyRemoteTexture(texture), + softDeleteByContentId: (id) => useCustomTextureStore.getState().softDeleteByContentId(id), + createBundleDir: async () => { + const id = uniqueId(); + await service.createDir(id, 'Images', true); + return id; + }, + queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) => + transferManager.queueReplicaDownload('texture', contentId, displayTitle, files, base), + filesExist: async (bundleDir, filenames) => { + for (const filename of filenames) { + const exists = await service.exists(`${bundleDir}/${filename}`, 'Images'); + if (!exists) return false; + } + return true; + }, + queueLocalBinaryUpload: async (record) => { + await queueReplicaBinaryUpload('texture', record, service); + }, + isAuthenticated: async () => !!(await getAccessToken()), +}); + const runPullForKind = async ( kind: ReplicaKind, service: AppService, @@ -130,6 +173,10 @@ const runPullForKind = async ( await replicaPullAndApply(buildFontPullDeps(ctx.manager, service, envConfig)); return; } + if (kind === 'texture') { + await replicaPullAndApply(buildTexturePullDeps(ctx.manager, service, envConfig)); + return; + } // Future: dispatch to other per-kind dep builders here. }; diff --git a/apps/readest-app/src/libs/replicaSchemas.ts b/apps/readest-app/src/libs/replicaSchemas.ts index b157a024..3b079157 100644 --- a/apps/readest-app/src/libs/replicaSchemas.ts +++ b/apps/readest-app/src/libs/replicaSchemas.ts @@ -44,6 +44,14 @@ const fontFieldsSchema = z }) .catchall(fieldEnvelopeWithCipher); +const textureFieldsSchema = z + .object({ + name: fieldEnvelopeSchema.optional(), + byteSize: fieldEnvelopeSchema.optional(), + downloadedAt: fieldEnvelopeSchema.optional(), + }) + .catchall(fieldEnvelopeWithCipher); + interface KindSpec { minSchemaVersion: number; maxSchemaVersion: number; @@ -67,6 +75,13 @@ export const KIND_ALLOWLIST: Record = { fields: fontFieldsSchema, binary: true, }, + texture: { + minSchemaVersion: 1, + maxSchemaVersion: 1, + maxRowsPerUser: 200, + fields: textureFieldsSchema, + binary: true, + }, }; export const isAllowedKind = (kind: string): boolean => Object.hasOwn(KIND_ALLOWLIST, kind); diff --git a/apps/readest-app/src/services/imageService.ts b/apps/readest-app/src/services/imageService.ts index 514b6f3a..88fa57b7 100644 --- a/apps/readest-app/src/services/imageService.ts +++ b/apps/readest-app/src/services/imageService.ts @@ -1,30 +1,76 @@ import { FileSystem } from '@/types/system'; import { getFilename } from '@/utils/path'; -import { CustomTextureInfo } from '@/styles/textures'; +import { md5, partialMD5 } from '@/utils/md5'; +import { uniqueId } from '@/utils/misc'; +import { CustomTextureInfo, getTextureName } from '@/styles/textures'; +/** + * Build the cross-device content id for a texture: + * `md5(partialMD5 ‖ byteSize ‖ filename)`. Same recipe shape as + * fontService.computeFontContentId — keeps the kinds aligned. + */ +export const computeTextureContentId = ( + partialMd5: string, + byteSize: number, + filename: string, +): string => md5(`${partialMd5}|${byteSize}|${filename}`); + +/** + * Import an image into the user's `Images` base under a per-texture + * bundle dir (`/`). The bundle dir is a fresh + * `uniqueId()` — matches the font / dictionary import pattern. Returns + * a CustomTextureInfo with the relative path, contentId, bundleDir, + * and byteSize populated so the store can publish the replica row + * immediately. + */ export async function importImage( fs: FileSystem, file?: string | File, ): Promise { - let imagePath: string; + const bundleDir = uniqueId(); + let filename: string; + let bytes: ArrayBuffer; + if (typeof file === 'string') { const filePath = file; const fileobj = await fs.openFile(filePath, 'None'); - imagePath = fileobj.name || getFilename(filePath); - await fs.copyFile(filePath, 'None', imagePath, 'Images'); + filename = fileobj.name || getFilename(filePath); + bytes = await fileobj.arrayBuffer(); } else if (file) { - imagePath = getFilename(file.name); - await fs.writeFile(imagePath, 'Images', file); + filename = getFilename(file.name); + bytes = await file.arrayBuffer(); } else { return null; } + const texturePath = `${bundleDir}/${filename}`; + await fs.createDir(bundleDir, 'Images', true); + await fs.writeFile(texturePath, 'Images', bytes); + + const textureFile = await fs.openFile(texturePath, 'Images'); + const partialMd5 = await partialMD5(textureFile); + const byteSize = bytes.byteLength; + const contentId = computeTextureContentId(partialMd5, byteSize, filename); + return { - name: imagePath.replace(/\.[^/.]+$/, ''), - path: imagePath, + name: getTextureName(filename), + path: texturePath, + bundleDir, + contentId, + byteSize, }; } export async function deleteImage(fs: FileSystem, texture: CustomTextureInfo): Promise { await fs.removeFile(texture.path, 'Images'); + // Also remove the per-texture bundle dir if it's now empty. Legacy + // textures without bundleDir live at the flat `Images/` + // path; nothing extra to clean up there. + if (texture.bundleDir) { + try { + await fs.removeDir(texture.bundleDir, 'Images', true); + } catch (err) { + console.warn('Failed to remove texture bundleDir', texture.bundleDir, err); + } + } } diff --git a/apps/readest-app/src/services/sync/adapters/texture.ts b/apps/readest-app/src/services/sync/adapters/texture.ts new file mode 100644 index 00000000..1cc5c55b --- /dev/null +++ b/apps/readest-app/src/services/sync/adapters/texture.ts @@ -0,0 +1,105 @@ +import { computeTextureContentId } from '@/services/imageService'; +import type { CustomTexture } from '@/styles/textures'; +import type { ReplicaAdapter } from '@/services/sync/replicaRegistry'; +import type { FieldEnvelope, FieldsObject, ReplicaRow } from '@/types/replica'; + +export const TEXTURE_KIND = 'texture'; +export const TEXTURE_SCHEMA_VERSION = 1; + +const unwrap = (env: FieldEnvelope | undefined): unknown => + env && typeof env === 'object' && 'v' in env ? (env as FieldEnvelope).v : undefined; + +interface UnwrappedTextureFields { + name?: string; + byteSize?: number; + downloadedAt?: number; +} + +const unwrapTextureFields = (fields: FieldsObject): UnwrappedTextureFields => { + const name = unwrap(fields['name']); + const byteSize = unwrap(fields['byteSize']); + const downloadedAt = unwrap(fields['downloadedAt']); + return { + name: typeof name === 'string' ? name : undefined, + byteSize: typeof byteSize === 'number' ? byteSize : undefined, + downloadedAt: typeof downloadedAt === 'number' ? downloadedAt : undefined, + }; +}; + +const filenameFromManifest = (row: ReplicaRow): string | null => { + // Texture kind is single-file; the manifest carries exactly one entry. + const f = row.manifest_jsonb?.files[0]; + return f?.filename ?? null; +}; + +export { computeTextureContentId }; + +export const textureAdapter: ReplicaAdapter = { + kind: TEXTURE_KIND, + schemaVersion: TEXTURE_SCHEMA_VERSION, + + pack(texture: CustomTexture): Record { + const fields: Record = { + name: texture.name, + downloadedAt: texture.downloadedAt ?? Date.now(), + }; + if (texture.byteSize !== undefined) fields['byteSize'] = texture.byteSize; + return fields; + }, + + unpack(fields: Record): CustomTexture { + return { + id: '', + name: String(fields['name'] ?? ''), + path: '', + byteSize: fields['byteSize'] !== undefined ? Number(fields['byteSize']) : undefined, + downloadedAt: + fields['downloadedAt'] !== undefined ? Number(fields['downloadedAt']) : undefined, + }; + }, + + async computeId(t: CustomTexture): Promise { + return t.contentId ?? t.id; + }, + + unpackRow(row: ReplicaRow, bundleDir: string): CustomTexture | null { + const fields = unwrapTextureFields(row.fields_jsonb); + if (!fields.name) return null; + const filename = filenameFromManifest(row); + if (!filename) { + // No manifest yet — placeholder with empty path; the manifest + // commit on the publishing device will fill this in on the next + // pull. Returning null skips the row for now (orchestrator + // tolerates re-pulling the same row later). + return null; + } + const texture: CustomTexture = { + id: bundleDir, + contentId: row.replica_id, + name: fields.name, + bundleDir, + path: `${bundleDir}/${filename}`, + unavailable: true, + }; + if (fields.byteSize !== undefined) texture.byteSize = fields.byteSize; + if (fields.downloadedAt !== undefined) texture.downloadedAt = fields.downloadedAt; + if (row.reincarnation) texture.reincarnation = row.reincarnation; + return texture; + }, + + binary: { + localBaseDir: 'Images', + enumerateFiles: (texture: CustomTexture) => { + // Textures are single-file. The texture's `path` is the lfp, + // relative to the Images base dir. + const filename = texture.path.split('/').pop() ?? texture.path; + return [ + { + logical: filename, + lfp: texture.path, + byteSize: texture.byteSize ?? 0, + }, + ]; + }, + }, +}; diff --git a/apps/readest-app/src/services/sync/replicaBootstrap.ts b/apps/readest-app/src/services/sync/replicaBootstrap.ts index 548e555a..bd511124 100644 --- a/apps/readest-app/src/services/sync/replicaBootstrap.ts +++ b/apps/readest-app/src/services/sync/replicaBootstrap.ts @@ -1,7 +1,9 @@ import { useCustomDictionaryStore } from '@/store/customDictionaryStore'; import { useCustomFontStore } from '@/store/customFontStore'; +import { useCustomTextureStore } from '@/store/customTextureStore'; import { dictionaryAdapter, DICTIONARY_KIND } from './adapters/dictionary'; import { fontAdapter, FONT_KIND } from './adapters/font'; +import { textureAdapter, TEXTURE_KIND } from './adapters/texture'; import { getReplicaPersistEnv } from './replicaPersist'; import { getReplicaAdapter, registerReplicaAdapter } from './replicaRegistry'; import { registerReplicaDownloadHandler } from './replicaTransferIntegration'; @@ -10,6 +12,7 @@ import type { ReplicaAdapter } from './replicaRegistry'; const KNOWN_ADAPTERS: ReplicaAdapter[] = [ dictionaryAdapter as unknown as ReplicaAdapter, fontAdapter as unknown as ReplicaAdapter, + textureAdapter as unknown as ReplicaAdapter, ]; let didBootstrap = false; @@ -41,6 +44,19 @@ export const bootstrapReplicaAdapters = (): void => { } void useCustomFontStore.getState().activateFontByContentId(env, replicaId); }); + // Textures: mark available + load the file into a blob URL so the + // panel grid renders the swatch and `applyTexture` can mount it + // without re-reading disk. Mounting only happens when the user + // selects the texture (via applyTexture), so no automatic mount + // here. Falls back to flag-only when persist env hasn't landed yet. + registerReplicaDownloadHandler(TEXTURE_KIND, (replicaId) => { + const env = getReplicaPersistEnv(); + if (!env) { + useCustomTextureStore.getState().markAvailableByContentId(replicaId); + return; + } + void useCustomTextureStore.getState().activateTextureByContentId(env, replicaId); + }); didBootstrap = true; }; diff --git a/apps/readest-app/src/store/customTextureStore.ts b/apps/readest-app/src/store/customTextureStore.ts index 5a027d32..cae80f89 100644 --- a/apps/readest-app/src/store/customTextureStore.ts +++ b/apps/readest-app/src/store/customTextureStore.ts @@ -8,13 +8,32 @@ import { 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 { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload'; +import { partialMD5 } from '@/utils/md5'; +import { uniqueId } from '@/utils/misc'; + +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) => CustomTexture; + addTexture: ( + path: string, + options?: Partial>, + ) => CustomTexture; removeTexture: (id: string) => boolean; updateTexture: (id: string, updates: Partial) => boolean; getTexture: (id: string) => CustomTexture | undefined; @@ -22,6 +41,28 @@ interface TextureStoreState { 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; + applyTexture: (envConfig: EnvConfigType, textureId: string) => Promise; loadTexture: (envConfig: EnvConfigType, textureId: string) => Promise; loadTextures: (envConfig: EnvConfigType, textureIds: string[]) => Promise; @@ -48,8 +89,8 @@ export const useCustomTextureStore = create((set, get) => ({ setTextures: (textures) => set({ textures }), - addTexture: (path) => { - const texture = createCustomTexture(path); + addTexture: (path, options) => { + const texture = createCustomTexture(path, options); const existingTexture = get().textures.find((t) => t.id === texture.id); if (existingTexture) { @@ -65,7 +106,9 @@ export const useCustomTextureStore = create((set, get) => ({ set((state) => ({ textures: [...state.textures], })); - return existingTexture; + const refreshed = get().getTexture(texture.id) ?? existingTexture; + publishTextureUpsert(refreshed); + return refreshed; } const newTexture = { @@ -77,6 +120,7 @@ export const useCustomTextureStore = create((set, get) => ({ textures: [...state.textures, newTexture], })); + publishTextureUpsert(newTexture); return newTexture; }, @@ -97,6 +141,7 @@ export const useCustomTextureStore = create((set, get) => ({ set((state) => ({ textures: [...state.textures], })); + if (texture.contentId) publishTextureDelete(texture.contentId); return result; }, @@ -115,6 +160,60 @@ export const useCustomTextureStore = create((set, get) => ({ 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); }, @@ -320,6 +419,99 @@ export const useCustomTextureStore = create((set, get) => ({ }, })); +/** + * 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. + * + * For each live texture with no `contentId`: + * 1. Read bytes from `Images/`. + * 2. Compute partialMD5 + size → contentId. + * 3. Mint `bundleDir = uniqueId()`; copy the file to + * `Images//` and remove the flat-path one. + * 4. Patch the in-memory record (contentId, bundleDir, byteSize, + * path), persist via saveCustomTextures, then publish through + * publishReplicaUpsert. + * + * Idempotent: a texture that already carries `contentId` is skipped. + * If the file is missing on disk, the migration leaves the record + * untouched. Per-texture failures are logged and don't block the rest. + */ +export const migrateLegacyTextures = async (envConfig: EnvConfigType): Promise => { + const candidates = useCustomTextureStore + .getState() + .textures.filter((t) => !t.contentId && !t.bundleDir && !t.deletedAt && !t.path.includes('/')); + if (candidates.length === 0) return; + + const appService = await envConfig.getAppService(); + const migrated: CustomTexture[] = []; + + for (const legacy of candidates) { + try { + const exists = await appService.exists(legacy.path, 'Images'); + if (!exists) continue; + + const file = await appService.openFile(legacy.path, 'Images'); + const bytes = await file.arrayBuffer(); + const partialMd5 = await partialMD5(file); + const byteSize = bytes.byteLength; + const filename = legacy.path; + const contentId = computeTextureContentId(partialMd5, byteSize, filename); + const bundleDir = uniqueId(); + const newPath = `${bundleDir}/${filename}`; + + await appService.createDir(bundleDir, 'Images', true); + await appService.copyFile(legacy.path, 'Images', newPath, 'Images'); + await appService.deleteFile(legacy.path, 'Images'); + + const next: CustomTexture = { + ...legacy, + contentId, + bundleDir, + byteSize, + path: newPath, + // Force a re-load on next render so the blob URL points at the + // new on-disk path. + blobUrl: undefined, + loaded: false, + error: undefined, + }; + useCustomTextureStore.getState().updateTexture(legacy.id, next); + migrated.push(next); + } catch (err) { + console.warn('migrateLegacyTextures: failed for', legacy.path, err); + } + } + + if (migrated.length === 0) return; + + try { + await useCustomTextureStore.getState().saveCustomTextures(envConfig); + } catch (err) { + console.warn('migrateLegacyTextures: save failed', err); + } + for (const texture of migrated) { + publishTextureUpsert(texture); + void queueReplicaBinaryUpload('texture', texture, appService); + } +}; + // Cleanup blob URLs before page unload if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { diff --git a/apps/readest-app/src/styles/textures.ts b/apps/readest-app/src/styles/textures.ts index ef404165..a1a1929a 100644 --- a/apps/readest-app/src/styles/textures.ts +++ b/apps/readest-app/src/styles/textures.ts @@ -8,6 +8,33 @@ export interface BackgroundTexture { url?: string; animated?: boolean; + /** + * Cross-device content hash. Set on imports new enough to participate + * in replica sync (`partialMD5 + byteSize + filename`). Legacy textures + * (created before replica sync) leave this undefined and never publish + * — re-import to enable cloud sync. + */ + contentId?: string; + /** + * Per-texture directory name relative to the `Images` base. New imports + * land at `/`; legacy imports keep their flat + * `` path with bundleDir undefined. + */ + bundleDir?: string; + /** File size in bytes — used by the replica manifest, optional for legacy. */ + byteSize?: number; + /** + * On a remote-pulled placeholder, set to true until the binary download + * lands. The transfer-complete handler clears it via the texture store's + * markAvailable hook. + */ + unavailable?: boolean; + /** + * Reincarnation token — opaque value that revives a tombstoned remote + * row. Mirrors the font / dictionary mechanism. + */ + reincarnation?: string; + downloadedAt?: number; deletedAt?: number; @@ -42,9 +69,18 @@ export function getTextureId(name: string): string { return md5Fingerprint(name); } -export function createCustomTexture(path: string): CustomTexture { - const name = getTextureName(path); +export function createCustomTexture( + path: string, + options?: Partial>, +): CustomTexture { + const name = options?.name || getTextureName(path); + // Spread options first so replica-sync fields (contentId, bundleDir, + // byteSize) flow through from the import path. Mirrors the + // createCustomFont fix — without the spread, addTexture silently + // drops those fields and publishFontUpsert / replica binary upload + // both no-op on missing contentId. return { + ...options, id: getTextureId(name), name, path,