const { sha256Hex } = require('./identityHash') const { CONTENT_VERSION, emptyProgress, projectProgress } = require('./progressContract') const copy = value => JSON.parse(JSON.stringify(value)) const KEY = 'tang-xuetang-v1:' // Dependency-injected for offline tests. Tokens remain in the existing host key // and request header only; local slots use a SHA-256 fingerprint, not the token. function createPlatformBridge(platform, config) { let active = null const listeners = new Set() function token() { try { return String(platform.getStorageSync('token') || '') } catch (_) { return '' } } function read(key, fallback) { try { return platform.getStorageSync(key) || fallback } catch (_) { return fallback } } function write(key, value) { try { platform.setStorageSync(key, copy(value)); return true } catch (_) { return false } } function context() { const current = token() if (active && active.token === current) return active if (active && active.timer) clearTimeout(active.timer) const scope = KEY + (current ? sha256Hex(current) : 'guest') const mapped = current ? read(scope + ':user', null) : null const key = Number.isSafeInteger(mapped) && mapped > 0 ? KEY + 'user:' + mapped : scope const cached = read(key, {}) active = { token: current, scope, key, verified: false, opening: null, flushing: null, timer: null, status: current ? 'offline' : 'guest', remote: null, state: { progress: emptyProgress(), revision: 0, story_generation: 0, user_id: null, dirty: false, resetPending: false, serial: 0, pending: null, ...cached } } return active } const current = c => active === c && token() === c.token function notify(c, status) { if (!current(c)) return c.status = status listeners.forEach(listener => { try { listener(status) } catch (_) {} }) } function persist(c) { const ok = write(c.key, c.state) if (!ok) notify(c, 'storage-error') return ok } async function request(c, path, method = 'GET', data) { if (!current(c) || !c.token) throw new Error('SESSION_CHANGED') return new Promise((resolve, reject) => platform.request({ url: String(config.apiBaseUrl).replace(/\/+$/, '') + '/api/tang/' + path, method, data, timeout: 8000, header: { token: c.token, 'content-type': 'application/json' }, success(result) { if (!current(c)) return reject(new Error('SESSION_CHANGED')) const body = result.data if (result.statusCode !== 200 || !body || typeof body !== 'object') return reject(new Error('API_UNAVAILABLE')) if (body.code === -1) { c.verified = false; notify(c, 'auth-expired'); return reject(new Error('AUTH_EXPIRED')) } if (body.code !== 1) { const error = new Error(body.data && body.data.error_code || 'API_UNAVAILABLE') return reject(error) } resolve(body.data) }, fail() { reject(new Error('NETWORK_UNAVAILABLE')) }, })) } function validateRemote(value) { if (!value || value.schema_version !== 1 || value.content_version !== CONTENT_VERSION || !Number.isSafeInteger(value.user_id) || value.user_id <= 0 || !Number.isSafeInteger(value.revision) || value.revision < 0 || !Number.isSafeInteger(value.story_generation) || value.story_generation < 0 || !value.progress || typeof value.progress !== 'object') throw new Error('CONTENT_MISMATCH') return { ...value, progress: projectProgress(value.progress) } } function hydrate(c, remote) { c.state.progress = { ...c.state.progress, ...remote.progress } c.state.revision = remote.revision c.state.story_generation = remote.story_generation c.state.user_id = remote.user_id c.state.pending = null c.state.resetPending = false c.state.dirty = false const saved = persist(c) if (saved) notify(c, 'synced') return saved } function failure(c, error) { if (!current(c) || error.message === 'SESSION_CHANGED') return if (['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message)) c.verified = false const permanent = ['INVALID_REQUEST', 'PAYLOAD_TOO_LARGE', 'IDEMPOTENCY_CONFLICT', 'UNSUPPORTED_MEDIA_TYPE', 'METHOD_NOT_ALLOWED'] if (c.status !== 'storage-error') notify(c, ['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message) ? 'auth-expired' : ['CONTENT_MISMATCH', 'UNSUPPORTED_CONTENT_VERSION'].includes(error.message) ? 'version-error' : permanent.includes(error.message) ? 'sync-error' : 'offline') } async function open(force = false) { const c = context() if (!c.token) return c.status if (c.opening) return c.opening if (c.verified && !force) return c.status c.opening = (async () => { c.verified = false notify(c, 'connecting') try { const [catalog, raw] = await Promise.all([request(c, 'catalog'), request(c, 'progress')]) if (!current(c)) return 'session-changed' if (!catalog || catalog.schema_version !== 1 || catalog.content_version !== CONTENT_VERSION) throw new Error('CONTENT_MISMATCH') const remote = validateRemote(raw) if (c.state.user_id && c.state.user_id !== remote.user_id) throw new Error('CONTENT_MISMATCH') // Only the authenticated server identity may select a shared user slot. // A renewed token can recover the same user's unsynced local queue. const userKey = KEY + 'user:' + remote.user_id if (c.key !== userKey) { const candidate = read(userKey, null) const saved = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : null if (saved && !c.state.dirty) c.state = saved else if (saved && c.state.dirty && !write(c.scope + ':previous-user-backup', saved)) throw new Error('STORAGE_UNAVAILABLE') c.key = userKey if (!write(c.scope + ':user', remote.user_id)) { notify(c, 'storage-error'); return c.status } } c.verified = true c.state.user_id = remote.user_id if (!c.state.dirty) hydrate(c, remote) else if (c.state.pending) await flushContext(c) // retry the exact idempotent request first else if (c.state.revision !== remote.revision || c.state.story_generation !== remote.story_generation) { c.remote = remote; notify(c, 'conflict') } else await flushContext(c) } catch (error) { failure(c, error) } finally { c.opening = null } return c.status })() return c.opening } function schedule(c) { if (c.timer) clearTimeout(c.timer) c.timer = setTimeout(() => { c.timer = null; flushContext(c) }, 600) } async function flushContext(c) { if (!current(c) || !c.verified || !c.state.dirty || ['conflict', 'storage-error', 'sync-error', 'version-error'].includes(c.status)) return if (c.flushing) return c.flushing // Defer preparation so the promise is assigned before any early exit. // The outer finally also covers a failed durable queue write. c.flushing = Promise.resolve().then(async () => { try { if (!current(c)) return if (!c.state.pending) { const operation = c.state.resetPending ? 'reset_story' : 'replace' const progress = projectProgress(c.state.progress) c.state.pending = { serial: operation === 'reset_story' ? c.state.resetAt : c.state.serial, body: { schema_version: 1, content_version: CONTENT_VERSION, base_revision: c.state.revision, story_generation: c.state.story_generation, request_id: 'tang-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 14), operation, progress: operation === 'reset_story' ? { ...emptyProgress(), collectedMemoryCards: progress.collectedMemoryCards } : progress, } } } if (!persist(c)) return const pending = copy(c.state.pending) notify(c, 'syncing') const remote = validateRemote(await request(c, 'saveProgress', 'POST', pending.body)) if (!current(c)) return if (remote.user_id !== c.state.user_id) throw new Error('CONTENT_MISMATCH') c.state.revision = remote.revision c.state.story_generation = remote.story_generation c.state.pending = null // A reset created while another request was in flight must not be lost. if (pending.body.operation === 'reset_story' && (c.state.resetAt || 0) <= pending.serial) c.state.resetPending = false if (pending.serial === c.state.serial) hydrate(c, remote) else if (persist(c)) { notify(c, 'pending'); schedule(c) } } catch (error) { if (error.message === 'PROGRESS_CONFLICT' && current(c)) { try { c.remote = validateRemote(await request(c, 'progress')); notify(c, 'conflict') } catch (readError) { failure(c, readError) } } else failure(c, error) } }).finally(() => { c.flushing = null }) return c.flushing } function saveProgress(value, reset = false) { const c = context() // A delayed callback from an old page/account must not enter the new slot. if (!value || value.__tangLocalScope !== c.scope) return false c.state.progress = copy(value) delete c.state.progress.__tangLocalScope c.state.dirty = true c.state.serial += 1 if (reset) { c.state.resetPending = true; c.state.resetAt = c.state.serial } if (!persist(c)) return false if (!['conflict', 'sync-error', 'version-error', 'auth-expired'].includes(c.status)) notify(c, c.token ? 'pending' : 'guest') if (c.verified) schedule(c) return true } function getConflictContext() { const c = context() return c.status === 'conflict' && c.remote ? JSON.stringify([ c.scope, c.remote.revision, c.remote.story_generation, c.state.serial, ]) : '' } async function resolveConflict(choice, expectedContext) { const c = context() if (!expectedContext || expectedContext !== getConflictContext() || !c.verified) return false // Recoverable, account-scoped backup before either explicit resolution. if (!write(c.key + ':conflict-backup', { local: c.state, remote: c.remote })) { notify(c, 'storage-error'); return false } if (choice === 'cloud') { const saved = hydrate(c, c.remote); if (saved) c.remote = null; return saved } if (choice !== 'local') return false c.state.revision = c.remote.revision c.state.story_generation = c.remote.story_generation c.state.pending = null c.remote = null if (!persist(c)) return false notify(c, 'pending') await flushContext(c) return c.status === 'synced' } return { open, saveProgress, resolveConflict, getConflictContext, getProgress: () => { const c = context(); return { ...copy(c.state.progress || {}), ...projectProgress(c.state.progress), __tangLocalScope: c.scope } }, getScope: () => context().scope, getStatus: () => context().status, flush: () => { const c = context(); if (c.timer) { clearTimeout(c.timer); c.timer = null } return flushContext(c) }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener) }, // Local preferences/audio are account-scoped and are never passed to request(). readLocal: (suffix, fallback) => copy(read(context().key + ':' + suffix, fallback)), writeLocal: (suffix, value) => write(context().key + ':' + suffix, value), dispose() { if (active && active.timer) clearTimeout(active.timer); listeners.clear(); active = null }, } } module.exports = { createPlatformBridge }