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.
341 lines
12 KiB
TypeScript
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());
|
|
}
|