freezmo/lib/sync.ts
Martin Dvorak d95c5c7151 Podcast mode: notes read aloud with a lyric-style word highlight
The third segment of the note-powered wizards (Study deck / Homework /
Podcast) takes the same intake - photos, documents, pasted text, the share
target - and turns it into a spoken episode.

- prompts: PODCAST_SYSTEM_PROMPT writes for the ear (plain prose, no markdown,
  no visual references), chaptered into 3-8 sections around a length target.
- /api/podcast mirrors /api/generate's plumbing (rate limit, model gate,
  provider keys, heartbeats, the 2x deadline, the optional vision pass).
- storage: frezmo.podcasts.v1, zod-validated, included in the backup and the
  encrypted sync payload; a new library tab lists episodes with listen /
  rename / delete-with-undo, and the wizard switch deep-links in.
- player: the transcript is word-wrapped spans and the word under the voice
  rides a lyric-style highlight that keeps itself centred. Two clocks: exact
  onboundary events where the browser reports them, a per-word length timer
  where it does not (iOS Safari); the first boundary retires the timer.
  Pause = cancel + re-speak from the word, which makes resume, chapter jumps
  and tap-any-word-to-seek the same operation; the position saves on pause,
  page-hide and unmount, so reopening resumes. Speed rebuilds the engine and
  keeps playing; the Aero soundtrack pauses itself when the voice starts; a
  browser with no voice gets an honest note and a readable transcript.
- the script sits on a glass pane (plain paragraphs over Aero's grass failed
  contrast; the scan now covers the player too).

tests/podcast-test.mjs (17th suite) stubs the model and the speech engine and
drives the highlight by hand: 26 checks. All suites green, contrast clean.
Version 0.7.3, SW v95.
2026-10-16 11:42:15 +00:00

341 lines
12 KiB
TypeScript

"use client";
/**
* Opt-in encrypted sync. The payload (decks, folders, homework — never the
* API key) is encrypted in this browser with AES-GCM using a key derived from
* the user's passphrase (PBKDF2-SHA256, 310k iterations). The server only
* ever stores the opaque envelope under a random sync ID.
*
* Conflict policy: server-stamped `updatedAt` is the clock; when both sides
* changed since the last sync the UI asks the user which one to keep.
*/
import { z } from "zod";
import { backupDataSchema } from "./backup";
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
import { ApiError } from "./client-api";
import { replaceFolders } from "./folders";
import { replaceHomework } from "./homework";
import { podcastStore, replacePodcasts } from "./podcasts";
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
import { localeHeader, t } from "./i18n";
import { authHeaders } from "./settings";
import { deckStore, replaceDecks } from "./storage";
import { homeworkStore } from "./homework";
import { folderStore } from "./folders";
import { clearDeviceKey, loadDeviceKey, saveDeviceKey } from "./sync-key";
import { newSyncId, setSyncRecord, syncStore, withoutDirtyTracking } from "./sync-state";
const KDF = "PBKDF2-SHA256";
const ITERATIONS = 310_000;
export type SyncEnvelope = {
v: 1;
kdf: "PBKDF2-SHA256";
iterations: number;
salt: string;
iv: string;
ciphertext: string;
};
export type SyncStatus = "pushed" | "pulled" | "up-to-date" | "conflict" | "locked" | "error" | "off";
export type SyncOutcome = { status: SyncStatus; message?: string };
const syncPayloadSchema = z.object({
app: z.literal("freezmo"),
kind: z.literal("sync"),
version: z.literal(1),
clientUpdatedAt: z.number(),
data: backupDataSchema,
});
type SyncPayload = z.infer<typeof syncPayloadSchema>;
/* ------------------------------------------------------------------ */
/* Encoding helpers (chunked: payloads can be megabytes) */
/* ------------------------------------------------------------------ */
function toBase64(bytes: Uint8Array): string {
let binary = "";
const chunk = 0x8000;
for (let index = 0; index < bytes.length; index += chunk) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
}
return btoa(binary);
}
function fromBase64(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
return bytes;
}
/* ------------------------------------------------------------------ */
/* Crypto */
/* ------------------------------------------------------------------ */
export async function deriveSyncKey(passphrase: string, saltBase64: string): Promise<CryptoKey> {
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(passphrase),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: fromBase64(saltBase64) as unknown as BufferSource, iterations: ITERATIONS, hash: "SHA-256" },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
async function encryptWithKey(key: CryptoKey, payload: unknown): Promise<{ iv: string; ciphertext: string }> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(payload));
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
return { iv: toBase64(iv), ciphertext: toBase64(new Uint8Array(cipher)) };
}
async function decryptWithKey(key: CryptoKey, ivBase64: string, ciphertextBase64: string): Promise<unknown> {
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromBase64(ivBase64) as unknown as BufferSource },
key,
fromBase64(ciphertextBase64) as unknown as BufferSource,
);
return JSON.parse(new TextDecoder().decode(plain));
}
/* ------------------------------------------------------------------ */
/* Server API */
/* ------------------------------------------------------------------ */
async function apiGet(id: string): Promise<{ updatedAt: number; envelope: SyncEnvelope } | null> {
let response: Response;
try {
response = await fetch(`/api/sync?id=${encodeURIComponent(id)}`, {
cache: "no-store",
headers: { ...authHeaders(), ...localeHeader() },
});
} catch {
throw new ApiError(t("settings.syncError"), "network");
}
if (response.status === 404) return null;
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
throw new ApiError(body?.error ?? t("settings.syncError"), "sync");
}
return (await response.json()) as { updatedAt: number; envelope: SyncEnvelope };
}
async function apiPut(id: string, envelope: SyncEnvelope): Promise<number> {
let response: Response;
try {
response = await fetch("/api/sync", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
body: JSON.stringify({ id, envelope }),
});
} catch {
throw new ApiError(t("settings.syncError"), "network");
}
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
throw new ApiError(body?.error ?? t("settings.syncError"), "sync");
}
const body = (await response.json()) as { updatedAt: number };
return body.updatedAt;
}
async function apiDelete(id: string): Promise<void> {
const response = await fetch(`/api/sync?id=${encodeURIComponent(id)}`, {
method: "DELETE",
headers: { ...authHeaders(), ...localeHeader() },
});
if (!response.ok) throw new ApiError(t("settings.syncError"), "sync");
}
/* ------------------------------------------------------------------ */
/* Payload */
/* ------------------------------------------------------------------ */
async function buildPayload(): Promise<SyncPayload> {
await ensureLearningHydrated();
await ensureChatsHydrated();
return {
app: "freezmo",
kind: "sync",
version: 1,
clientUpdatedAt: Date.now(),
data: {
decks: deckStore.getSnapshot(),
folders: folderStore.getSnapshot(),
homework: homeworkStore.getSnapshot(),
podcasts: podcastStore.getSnapshot(),
history: learningStore.getAnswersSnapshot(),
srs: learningStore.getSrsSnapshot(),
chats: chatHistoryStore.getSnapshot(),
},
};
}
async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope: SyncEnvelope }): Promise<void> {
const parsed = syncPayloadSchema.parse(
await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext),
);
withoutDirtyTracking(() => {
replaceFolders(parsed.data.folders);
replaceDecks(parsed.data.decks);
replaceHomework(parsed.data.homework);
replacePodcasts(parsed.data.podcasts ?? []);
replaceChats(parsed.data.chats ?? []);
});
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false });
}
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
export async function syncNow(strategy: "auto" | "push" | "pull" = "auto"): Promise<SyncOutcome> {
const record = syncStore.getSnapshot();
if (!record.enabled || !record.id) return { status: "off" };
const key = await loadDeviceKey();
if (!key) return { status: "locked" };
try {
const remote = await apiGet(record.id);
const remoteIsNewer = Boolean(remote && remote.updatedAt > (record.lastSyncedAt ?? 0));
if (strategy === "pull") {
if (!remote) return { status: "error", message: t("server.syncNotFound") };
await applyRemote(key, remote);
return { status: "pulled" };
}
if (strategy === "auto" && remoteIsNewer) {
if (record.dirtyAt) {
setSyncRecord({ conflict: true });
return { status: "conflict" };
}
await applyRemote(key, remote!);
return { status: "pulled" };
}
if (strategy === "auto" && !remoteIsNewer && !record.dirtyAt) {
return { status: "up-to-date" };
}
const { iv, ciphertext } = await encryptWithKey(key, await buildPayload());
const envelope: SyncEnvelope = {
v: 1,
kdf: KDF,
iterations: ITERATIONS,
salt: record.salt,
iv,
ciphertext,
};
const updatedAt = await apiPut(record.id, envelope);
setSyncRecord({ lastSyncedAt: updatedAt, dirtyAt: null, conflict: false });
return { status: "pushed" };
} catch (error) {
return {
status: "error",
message: error instanceof ApiError ? error.message : t("settings.syncError"),
};
}
}
/** First device: brand-new id and salt, uploads straight away. */
export async function enableSync(passphrase: string): Promise<SyncOutcome> {
const id = newSyncId();
const salt = toBase64(crypto.getRandomValues(new Uint8Array(16)));
const key = await deriveSyncKey(passphrase, salt);
await saveDeviceKey(key);
setSyncRecord({ enabled: true, id, salt, lastSyncedAt: null, dirtyAt: Date.now(), conflict: false });
return syncNow("push");
}
/** Same device after the browser dropped the stored key. */
export async function unlockSync(passphrase: string): Promise<SyncOutcome> {
const record = syncStore.getSnapshot();
if (!record.enabled || !record.id) return { status: "off" };
try {
const remote = await apiGet(record.id);
const salt = remote?.envelope.salt || record.salt;
if (!salt) return { status: "error", message: t("settings.syncError") };
const key = await deriveSyncKey(passphrase, salt);
if (remote) {
try {
syncPayloadSchema.parse(await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext));
} catch {
return { status: "error", message: t("settings.syncWrongPass") };
}
}
await saveDeviceKey(key);
setSyncRecord({ salt });
return syncNow("auto");
} catch (error) {
return {
status: "error",
message: error instanceof ApiError ? error.message : t("settings.syncError"),
};
}
}
/** New device: the sync ID plus the passphrase from the other device. */
export async function joinSync(id: string, passphrase: string): Promise<SyncOutcome> {
try {
const remote = await apiGet(id.trim());
if (!remote) return { status: "error", message: t("server.syncNotFound") };
const key = await deriveSyncKey(passphrase, remote.envelope.salt);
try {
syncPayloadSchema.parse(await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext));
} catch {
return { status: "error", message: t("settings.syncWrongPass") };
}
await saveDeviceKey(key);
setSyncRecord({
enabled: true,
id: id.trim(),
salt: remote.envelope.salt,
lastSyncedAt: null,
dirtyAt: null,
conflict: false,
});
await applyRemote(key, remote);
return { status: "pulled" };
} catch (error) {
return {
status: "error",
message: error instanceof ApiError ? error.message : t("settings.syncError"),
};
}
}
export async function deleteSyncedData(): Promise<SyncOutcome> {
const record = syncStore.getSnapshot();
try {
if (record.id) await apiDelete(record.id);
} catch (error) {
return {
status: "error",
message: error instanceof ApiError ? error.message : t("settings.syncError"),
};
}
await clearDeviceKey();
setSyncRecord({ enabled: false, id: "", salt: "", lastSyncedAt: null, dirtyAt: null, conflict: false });
return { status: "up-to-date" };
}
export async function forgetDeviceKey(): Promise<void> {
await clearDeviceKey();
}
export async function hasDeviceKey(): Promise<boolean> {
return Boolean(await loadDeviceKey());
}