1import { openDB, type DBSchema, type IDBPDatabase } from "idb"
2
3import { LAYOUTS, type LayoutId } from "@/lib/layouts"
4import type {
5 RelaySession,
6 ShareState,
7 Shot,
8 StickerInstance,
9 StripText,
10} from "@/stores/booth"
11
12/* FR-14: sesi bertahan saat halaman dimuat ulang. Hanya state yang perlu
13 dipulihkan yang disimpan; pengaturan perangkat punya jalurnya sendiri
14 (gesture GS-06 di localStorage, kamera memang sesi saja). IndexedDB dipakai
15 lewat `idb` (ARCHITECTURE §2: klien penyimpanan tidak ditulis sendiri).
16 Nilai yang dibaca kembali divalidasi lebih dulu, jadi rekaman dari versi
17 lain atau yang rusak tidak pernah masuk ke store. */
18
19export const SESSION_VERSION = 1
20const DB_NAME = "snapme-session"
21const STORE = "state"
22const KEY = "current"
23
24interface SessionRecord {
25 version: typeof SESSION_VERSION
26 layoutId: LayoutId | null
27 shots: (Shot | null)[]
28 frameId: string
29 effectId: string
30 stickers: StickerInstance[]
31 texts: StripText[]
32 showDate: boolean
33 /** M6: pilihan QR dan id link. Token hapus sengaja tidak ikut; ia tinggal di
34 * localStorage (`features/share/stripToken.ts`), jadi rekaman ini tidak
35 * berisi rahasia. */
36 share: ShareState
37 /** M8: irisan estafet bila sesi ini milik estafet. Token tidak ikut, sama
38 * seperti token hapus: ia tinggal di perangkat (`features/relay/relayToken.ts`).
39 * Rekaman lama tanpa bidang ini tetap valid. */
40 relay?: RelaySession | null
41 /** M8 (SE-10): sesi lokal dari estafet yang sudah selesai; menetap supaya
42 * muat ulang tidak mengubahnya jadi sesi yang bisa mengunggah lagi. */
43 localOnly?: boolean
44}
45
46/** Bagian state sesi yang disimpan. `settings` dan `sessionReady` bukan milik
47 * rekaman. */
48export type SessionState = Pick<
49 SessionRecord,
50 | "layoutId"
51 | "shots"
52 | "frameId"
53 | "effectId"
54 | "stickers"
55 | "texts"
56 | "showDate"
57 | "share"
58 | "relay"
59 | "localOnly"
60>
61
62interface SessionDb extends DBSchema {
63 state: { key: string; value: SessionRecord }
64}
65
66let dbPromise: Promise<IDBPDatabase<SessionDb>> | null = null
67
68function openSessionDb(): Promise<IDBPDatabase<SessionDb>> {
69 dbPromise ??= openDB<SessionDb>(DB_NAME, 1, {
70 upgrade(db) {
71 db.createObjectStore(STORE)
72 },
73 })
74 return dbPromise
75}
76
77function isNumber(value: unknown): value is number {
78 return typeof value === "number" && Number.isFinite(value)
79}
80
81function isShot(value: unknown): value is Shot {
82 if (typeof value !== "object" || value === null) {
83 return false
84 }
85 const shot = value as Record<string, unknown>
86 return typeof shot.still === "string" && isNumber(shot.capturedAt)
87}
88
89function isSticker(value: unknown): value is StickerInstance {
90 if (typeof value !== "object" || value === null) {
91 return false
92 }
93 const sticker = value as Record<string, unknown>
94 return (
95 typeof sticker.id === "string" &&
96 typeof sticker.assetId === "string" &&
97 isNumber(sticker.x) &&
98 isNumber(sticker.y) &&
99 isNumber(sticker.rotation) &&
100 isNumber(sticker.width)
101 )
102}
103
104function isText(value: unknown): value is StripText {
105 if (typeof value !== "object" || value === null) {
106 return false
107 }
108 const text = value as Record<string, unknown>
109 return (
110 typeof text.id === "string" &&
111 typeof text.value === "string" &&
112 isNumber(text.x) &&
113 isNumber(text.y) &&
114 isNumber(text.rotation) &&
115 isNumber(text.fontSize)
116 )
117}
118
119/* Panjang id yang diterima: nanoid saat ini 12 karakter, dan rentang ini
120 memberi ruang bila panjangnya berubah tanpa melemahkan pemeriksaan. */
121const SHARE_ID_MIN = 8
122const SHARE_ID_MAX = 32
123
124function isShare(value: unknown): value is ShareState {
125 if (typeof value !== "object" || value === null) {
126 return false
127 }
128 const share = value as Record<string, unknown>
129 if (typeof share.qrEnabled !== "boolean") {
130 return false
131 }
132 if (share.strip === null) {
133 return true
134 }
135 if (typeof share.strip !== "object") {
136 return false
137 }
138 const strip = share.strip as Record<string, unknown>
139 if (
140 typeof strip.id !== "string" ||
141 strip.id.length < SHARE_ID_MIN ||
142 strip.id.length > SHARE_ID_MAX
143 ) {
144 return false
145 }
146 return strip.expiresAt === null || typeof strip.expiresAt === "string"
147}
148
149function isRelay(value: unknown): value is RelaySession {
150 if (typeof value !== "object" || value === null) {
151 return false
152 }
153 const relay = value as Record<string, unknown>
154 if (
155 typeof relay.id !== "string" ||
156 relay.id.length < SHARE_ID_MIN ||
157 relay.id.length > SHARE_ID_MAX
158 ) {
159 return false
160 }
161 if (relay.role !== "host" && relay.role !== "guest") {
162 return false
163 }
164 return (
165 Array.isArray(relay.lockedSlots) &&
166 relay.lockedSlots.every((slot) => isNumber(slot) && Number.isInteger(slot)) &&
167 typeof relay.ghostEnabled === "boolean"
168 )
169}
170
171export function serializeSession(state: SessionState): SessionRecord {
172 return {
173 version: SESSION_VERSION,
174 layoutId: state.layoutId,
175 /* M7: bingkai Strip Hidup (`Shot.clip`) sengaja tidak ikut rekaman. Bentuk
176 fotonya disalin ulang di sini supaya `ImageBitmap` tidak pernah menyentuh
177 IndexedDB dan rekaman lama tetap valid. */
178 shots: state.shots.map((shot) =>
179 shot === null ? null : { still: shot.still, capturedAt: shot.capturedAt }
180 ),
181 frameId: state.frameId,
182 effectId: state.effectId,
183 stickers: state.stickers,
184 texts: state.texts,
185 showDate: state.showDate,
186 share: state.share,
187 relay: state.relay ?? null,
188 localOnly: state.localOnly ?? false,
189 }
190}
191
192/** Menerima hanya rekaman versi ini dengan bentuk yang benar; selain itu null,
193 * dan pemanggil membuang rekamannya. */
194export function validateRecord(value: unknown): SessionRecord | null {
195 if (typeof value !== "object" || value === null) {
196 return null
197 }
198 const record = value as Record<string, unknown>
199 if (record.version !== SESSION_VERSION) {
200 return null
201 }
202
203 const { layoutId } = record
204 if (
205 layoutId !== null &&
206 (typeof layoutId !== "string" || !(layoutId in LAYOUTS))
207 ) {
208 return null
209 }
210 /* Invarian store: jumlah slot selalu sama dengan shotCount layout terpilih. */
211 const expectedShots =
212 layoutId === null ? 0 : LAYOUTS[layoutId as LayoutId].shotCount
213 if (
214 !Array.isArray(record.shots) ||
215 record.shots.length !== expectedShots ||
216 !record.shots.every((shot) => shot === null || isShot(shot))
217 ) {
218 return null
219 }
220
221 if (typeof record.frameId !== "string" || typeof record.effectId !== "string") {
222 return null
223 }
224 if (
225 !Array.isArray(record.stickers) ||
226 !record.stickers.every(isSticker) ||
227 !Array.isArray(record.texts) ||
228 !record.texts.every(isText)
229 ) {
230 return null
231 }
232 if (typeof record.showDate !== "boolean") {
233 return null
234 }
235 /* Rekaman sebelum M6 tidak punya `share`; bentuknya diterima apa adanya
236 dengan nilai awal, jadi versi rekaman tidak perlu dinaikkan. */
237 const share = record.share
238 if (share !== undefined && !isShare(share)) {
239 return null
240 }
241 /* M8: rekaman sebelum estafet tidak punya `relay`; bentuknya diterima apa
242 adanya dengan nilai null, jadi versi rekaman tidak perlu dinaikkan. */
243 const relay = record.relay
244 if (relay !== undefined && relay !== null && !isRelay(relay)) {
245 return null
246 }
247 const { localOnly } = record
248 if (localOnly !== undefined && typeof localOnly !== "boolean") {
249 return null
250 }
251
252 return {
253 version: SESSION_VERSION,
254 layoutId: layoutId as LayoutId | null,
255 shots: record.shots as (Shot | null)[],
256 frameId: record.frameId,
257 effectId: record.effectId,
258 stickers: record.stickers as StickerInstance[],
259 texts: record.texts as StripText[],
260 showDate: record.showDate,
261 share: share === undefined ? { qrEnabled: false, strip: null } : share,
262 relay: relay === undefined ? null : (relay as RelaySession | null),
263 localOnly: localOnly === true,
264 }
265}
266
267export async function loadSession(): Promise<unknown> {
268 try {
269 return await (await openSessionDb()).get(STORE, KEY)
270 } catch {
271 /* Mode privat atau IndexedDB diblokir: sesi tetap jalan di memori. */
272 return null
273 }
274}
275
276export async function saveSession(record: SessionRecord): Promise<void> {
277 try {
278 await (await openSessionDb()).put(STORE, record, KEY)
279 } catch {
280 /* Tanpa penyimpanan, sesi hanya tidak pulih setelah muat ulang. */
281 }
282}
283
284export async function clearSession(): Promise<void> {
285 try {
286 await (await openSessionDb()).delete(STORE, KEY)
287 } catch {
288 /* Tidak ada yang bisa dihapus. */
289 }
290}
291