forked from akai/readest
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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user