6e7c9d1395
* feat(sync): add bundled `settings` replica kind for cross-device prefs and credentials Adds a single-row `settings` replica that syncs a whitelist of `SystemSettings` fields across devices via per-field LWW (one entry per dot-namespaced path). Plaintext for theme / highlight colour / TTS configuration; encrypted (AES-GCM under the user's sync passphrase) for kosync / Readwise / Hardcover credentials. Highlights: - Push-side diff against an in-memory snapshot for plaintext paths and a localStorage SHA-256 hash for encrypted paths, so a refresh doesn't re-publish or re-prompt for the passphrase. - Pull-side cipher-fingerprint dedupe + per-row passphrase gate; decryption failures surface as toasts (wrong passphrase / orphan cipher) instead of silent drops. - Auto-recovery for orphaned ciphers: when a row references a saltId no longer in `replica_keys`, clear the local hash and re-encrypt under the current salt on the next save. - Single in-flight `/sync/replica-keys` fetch with a value cache to coalesce the boot-time burst of concurrent unlock callers. * fix(sync): guard settings dot-path helpers against prototype-polluting keys Reject `__proto__`, `constructor`, and `prototype` segments in the settings adapter's `readPath` / `writePath`. Every caller currently passes a constant from `SETTINGS_WHITELIST`, so the guard is purely defensive — but it silences the CodeQL prototype-pollution warning on PR #4094 and keeps the helpers safe if a future call site ever forwards an untrusted path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 lines
3.2 KiB
TypeScript
72 lines
3.2 KiB
TypeScript
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 { opdsCatalogAdapter } from './adapters/opdsCatalog';
|
|
import { settingsAdapter } from './adapters/settings';
|
|
import { getReplicaPersistEnv } from './replicaPersist';
|
|
import { getReplicaAdapter, registerReplicaAdapter } from './replicaRegistry';
|
|
import { registerReplicaDownloadHandler } from './replicaTransferIntegration';
|
|
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>,
|
|
// Metadata-only — no binary download handler needed.
|
|
opdsCatalogAdapter as unknown as ReplicaAdapter<unknown>,
|
|
// Bundled scalar settings — singleton row, no binary.
|
|
settingsAdapter as unknown as ReplicaAdapter<unknown>,
|
|
];
|
|
|
|
let didBootstrap = false;
|
|
|
|
export const bootstrapReplicaAdapters = (): void => {
|
|
if (didBootstrap) return;
|
|
for (const adapter of KNOWN_ADAPTERS) {
|
|
if (getReplicaAdapter(adapter.kind)) continue;
|
|
registerReplicaAdapter(adapter);
|
|
}
|
|
// Per-kind download-completion handlers — fired by
|
|
// replicaTransferIntegration once binaries are on disk. Each store
|
|
// exposes a markAvailable* method that clears the placeholder
|
|
// `unavailable` flag set by the pull orchestrator.
|
|
registerReplicaDownloadHandler(DICTIONARY_KIND, (replicaId) => {
|
|
useCustomDictionaryStore.getState().markAvailableByContentId(replicaId);
|
|
});
|
|
// Fonts need more than the unavailable flag cleared: the binary must
|
|
// be loaded into a blob URL and the @font-face rule injected, the
|
|
// same plumbing manual import does in CustomFonts.tsx. Without this
|
|
// the auto-downloaded font appears in the UI but renders in a
|
|
// fallback face. Falls back to flag-only when persist env hasn't
|
|
// landed yet (extremely early boot).
|
|
registerReplicaDownloadHandler(FONT_KIND, (replicaId) => {
|
|
const env = getReplicaPersistEnv();
|
|
if (!env) {
|
|
useCustomFontStore.getState().markAvailableByContentId(replicaId);
|
|
return;
|
|
}
|
|
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;
|
|
};
|
|
|
|
export const __resetBootstrapForTests = (): void => {
|
|
didBootstrap = false;
|
|
};
|