feat(sync): cross-device background texture sync (#4079)

Plug the texture replica adapter into the kind-agnostic primitives
shipped in #4077. Textures imported on one device download and
become available on every signed-in device, with the same shape as
the font sync stack (single-file binary, contentId from
partialMD5+size+filename, bundleDir layout, replica-publish on
import, full activation on auto-download).

Includes legacy flat-path migration so pre-existing textures sync
without re-import, ColorPanel import flow now publishes the row
and queues the binary upload, and createCustomTexture preserves
contentId/bundleDir/byteSize through addTexture (mirrors the
font-import fix). Server allowlist gains 'texture' with a
single-image Zod schema; useBackgroundTexture passes replica
metadata through addTexture so the boot-time "ensure selected
texture is in store" path doesn't silently un-publish a remote
record.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-07 15:55:32 +08:00
committed by GitHub
parent ca674bb5e7
commit de6529523f
15 changed files with 671 additions and 34 deletions
@@ -28,10 +28,10 @@ const baseRow = (overrides: Partial<ReplicaRow> = {}): 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);
});
@@ -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);
@@ -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> = {}): 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> = {}): 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<string, unknown>)['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}$/);
});
});
@@ -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']);
});
});
+4 -4
View File
@@ -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',
);
@@ -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);
@@ -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<SettingsPanelPanelProp> = ({ 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);
@@ -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);
+48 -1
View File
@@ -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<CustomTexture> => ({
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.
};
@@ -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<string, KindSpec> = {
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);
+54 -8
View File
@@ -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 (`<bundleDir>/<filename>`). 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<CustomTextureInfo | null> {
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<void> {
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/<filename>`
// 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);
}
}
}
@@ -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<CustomTexture> = {
kind: TEXTURE_KIND,
schemaVersion: TEXTURE_SCHEMA_VERSION,
pack(texture: CustomTexture): Record<string, unknown> {
const fields: Record<string, unknown> = {
name: texture.name,
downloadedAt: texture.downloadedAt ?? Date.now(),
};
if (texture.byteSize !== undefined) fields['byteSize'] = texture.byteSize;
return fields;
},
unpack(fields: Record<string, unknown>): 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<string> {
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,
},
];
},
},
};
@@ -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<unknown>[] = [
dictionaryAdapter as unknown as ReplicaAdapter<unknown>,
fontAdapter as unknown as ReplicaAdapter<unknown>,
textureAdapter as unknown as ReplicaAdapter<unknown>,
];
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;
};
@@ -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<Omit<CustomTexture, 'id' | 'path'>>,
) => CustomTexture;
removeTexture: (id: string) => boolean;
updateTexture: (id: string, updates: Partial<CustomTexture>) => 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<void>;
applyTexture: (envConfig: EnvConfigType, textureId: string) => Promise<void>;
loadTexture: (envConfig: EnvConfigType, textureId: string) => Promise<CustomTexture>;
loadTextures: (envConfig: EnvConfigType, textureIds: string[]) => Promise<CustomTexture[]>;
@@ -48,8 +89,8 @@ export const useCustomTextureStore = create<TextureStoreState>((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<TextureStoreState>((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<TextureStoreState>((set, get) => ({
textures: [...state.textures, newTexture],
}));
publishTextureUpsert(newTexture);
return newTexture;
},
@@ -97,6 +141,7 @@ export const useCustomTextureStore = create<TextureStoreState>((set, get) => ({
set((state) => ({
textures: [...state.textures],
}));
if (texture.contentId) publishTextureDelete(texture.contentId);
return result;
},
@@ -115,6 +160,60 @@ export const useCustomTextureStore = create<TextureStoreState>((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<TextureStoreState>((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/<filename>`.
* 2. Compute partialMD5 + size → contentId.
* 3. Mint `bundleDir = uniqueId()`; copy the file to
* `Images/<bundleDir>/<filename>` 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<void> => {
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', () => {
+38 -2
View File
@@ -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 `<bundleDir>/<filename>`; legacy imports keep their flat
* `<filename>` 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<Omit<CustomTexture, 'id' | 'path'>>,
): 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,