diff --git a/app/page.tsx b/app/page.tsx index 1d42bd0..97db554 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -17,7 +17,7 @@ import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelCon import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction"; import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare"; import { AUTH_ENABLED } from "@/lib/supabase/config"; -import { createClient as createSupabaseClient } from "@/lib/supabase/client"; +import { isAuthed, writeResumeSnapshot } from "@/lib/authResume"; import { AuthModal } from "@/components/AuthModal"; import { UserChip } from "@/components/UserChip"; @@ -854,15 +854,6 @@ function CategorySelect({ const PENDING_START_KEY = "infiplot:pending-start"; const PENDING_PARSE_KEY = "infiplot:pending-parse"; -// True when auth is disabled (self-host with blank Supabase env) or the visitor -// already has a session. Gates the vision call behind login. -async function isAuthed(): Promise { - if (!AUTH_ENABLED) return true; - const sb = createSupabaseClient(); - const { data } = await sb.auth.getUser(); - return !!data.user; -} - // Shared by the StyleModal uploader and the post-login resume path: turns a // resized data URL into an English style prompt, via the browser engine when a // BYO model config is present, otherwise the server route. @@ -1399,19 +1390,11 @@ export default function HomePage() { const persistPendingStart = () => { const snap = { prompt, sel, customStyleGuide, customStyleRefImage, playerName }; - try { - sessionStorage.setItem(PENDING_START_KEY, JSON.stringify(snap)); - } catch { - // Quota is usually blown by the data-URL style ref; drop it, keep text. - try { - sessionStorage.setItem( - PENDING_START_KEY, - JSON.stringify({ ...snap, customStyleRefImage: "" }), - ); - } catch { - /* still too big — give up on resume, the form just clears */ - } - } + // Quota fallback: the data-URL style ref (~100KB) is the usual culprit — + // drop it first; text-only form still resumes the start. + writeResumeSnapshot(PENDING_START_KEY, snap, [ + { ...snap, customStyleRefImage: "" }, + ]); }; const resumePendingParse = async () => { @@ -1461,12 +1444,15 @@ export default function HomePage() { // is now signed in, restore and continue; otherwise clear stale snapshots. useEffect(() => { if (!AUTH_ENABLED) return; - const hasPending = - sessionStorage.getItem(PENDING_START_KEY) !== null || - sessionStorage.getItem(PENDING_PARSE_KEY) !== null; - if (!hasPending) return; + const hasStart = sessionStorage.getItem(PENDING_START_KEY) !== null; + const hasParse = sessionStorage.getItem(PENDING_PARSE_KEY) !== null; + if (!hasStart && !hasParse) return; let cancelled = false; void (async () => { + // Gate BOTH snapshots on auth: a stale leftover from an abandoned login + // must not resurrect a half-flow. The parse key stores a raw data URL + // with its own restore path (resumePendingParse), so both are gated + // manually here rather than via consumeResumeSnapshot. if (!(await isAuthed())) { sessionStorage.removeItem(PENDING_START_KEY); sessionStorage.removeItem(PENDING_PARSE_KEY); @@ -1492,10 +1478,11 @@ export default function HomePage() { const start = async () => { if (AUTH_ENABLED) { - const sb = createSupabaseClient(); - const { data } = await sb.auth.getUser(); - if (!data.user) { - persistPendingStart(); + if (!(await isAuthed())) { + // Don't snapshot here — persistPendingStart fires via + // AuthModal.onBeforeOAuth at redirect time, so the form is captured + // for BOTH OAuth and (harmlessly) OTP paths at the single source of + // truth. OTP's onSuccess resumes in-place without needing the snapshot. setPendingAction("start"); setAuthModalOpen(true); return; @@ -1992,7 +1979,8 @@ export default function HomePage() { onSuccess={() => { setAuthModalOpen(false); // Email-OTP stays on the page, so resume inline: parse first (it - // reads its own snapshot), then the pending start. + // reads its own snapshot), then the pending start. OTP never + // triggers onBeforeOAuth, so no PENDING_START snapshot was written. void resumePendingParse(); if (pendingAction === "start") { setPendingAction(null); @@ -2004,6 +1992,10 @@ export default function HomePage() { start(); } }} + // Snapshot the form the instant the OAuth redirect begins — the + // round-trip unmounts the page and discards in-memory state. Fires + // only for Google/GitHub (signInWithOAuth), not OTP. + onBeforeOAuth={persistPendingStart} /> )} diff --git a/app/play/page.tsx b/app/play/page.tsx index 7bc87a3..00db2d7 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -52,7 +52,7 @@ import type { } from "@infiplot/types"; import { track } from "@/lib/analytics"; import { AUTH_ENABLED } from "@/lib/supabase/config"; -import { createClient as createSupabaseClient } from "@/lib/supabase/client"; +import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume"; import { AuthModal } from "@/components/AuthModal"; import { UserChip } from "@/components/UserChip"; @@ -709,23 +709,15 @@ function PlayInner() { imageOriginalUrl, pendingAction: pendingResumeActionRef.current ?? undefined, }; - const tryWrite = (payload: PlayResumeSnapshot): boolean => { - try { - sessionStorage.setItem(PLAY_RESUME_KEY, JSON.stringify(payload)); - return true; - } catch { - return false; // QuotaExceededError — try a lighter payload below - } - }; - if (tryWrite(snap)) return; - // Drop the heaviest data-URL field (user-uploaded style ref, ~100KB) and - // retry. It only affects the Painter on FUTURE scenes, not the resumed - // scene, so losing it degrades gracefully. Voices are deliberately kept — - // preserving voice continuity matters more than the rare quota miss, and a - // typical session (remote-image URLs + a few ~160KB voice refs) fits well - // under sessionStorage's 5MB cap. If still too big, give up: resume is - // disabled for this trip and the page falls back to normal bootstrap. - tryWrite({ ...snap, session: { ...sess, styleReferenceImage: undefined } }); + // Quota-safe write: the only heavy field is the user-uploaded style ref + // (~100KB data URL), which only affects the Painter on FUTURE scenes, not + // the resumed scene — so stripping it degrades gracefully. Voices are + // deliberately kept (continuity > rare quota miss; a typical session of + // remote-image URLs + a few ~160KB voice refs fits under the 5MB cap). + writeResumeSnapshot(PLAY_RESUME_KEY, snap, [ + // Fallback: drop the style-reference data URL from the session. + { ...snap, session: { ...sess, styleReferenceImage: undefined } }, + ]); }, []); // Restore an in-progress game from a PLAY_RESUME_KEY snapshot after an OAuth @@ -1487,33 +1479,28 @@ function PlayInner() { // login never writes a snapshot — its onSuccess retry keeps state // in-memory. if (AUTH_ENABLED) { - const resumeRaw = sessionStorage.getItem(PLAY_RESUME_KEY); - if (resumeRaw) { - sessionStorage.removeItem(PLAY_RESUME_KEY); - // Let the async resume run; on failure it relinquishes the slot so the - // normal bootstrap below re-runs. Either way return here — the sync - // body must not run while the OAuth return is being reconciled. - void (async () => { - try { - const sb = createSupabaseClient(); - const { data } = await sb.auth.getUser(); - if (!data.user) { - // OAuth failed/not signed in → fall back to normal bootstrap. - startedRef.current = false; - setRetryBootstrap((n) => n + 1); - return; - } - await restorePlayResume( - JSON.parse(resumeRaw) as PlayResumeSnapshot, - ); - } catch { - // Corrupt snapshot / network — relinquish and bootstrap normally. - startedRef.current = false; - setRetryBootstrap((n) => n + 1); - } - })(); - return; - } + // Let the async resume run; on failure (no snapshot / not signed in / + // corrupt) it relinquishes the slot so the normal bootstrap below + // re-runs. Either way return here — the sync body must not run while the + // OAuth return is being reconciled. + void (async () => { + const snap = await consumeResumeSnapshot( + PLAY_RESUME_KEY, + ); + if (!snap) { + startedRef.current = false; + setRetryBootstrap((n) => n + 1); + return; + } + try { + await restorePlayResume(snap); + } catch { + // Corrupt snapshot / network — relinquish and bootstrap normally. + startedRef.current = false; + setRetryBootstrap((n) => n + 1); + } + })(); + return; } // 三条进入路径: diff --git a/lib/authResume.ts b/lib/authResume.ts new file mode 100644 index 0000000..8def69d --- /dev/null +++ b/lib/authResume.ts @@ -0,0 +1,72 @@ +// Shared primitives for surviving an OAuth full-page round-trip. +// +// Google / GitHub OAuth is a full-page redirect: it unmounts the React tree +// and discards all in-memory state (the server is stateless, so the client +// carries everything). To resume where the user left off after the redirect, +// a page snapshots its domain state into sessionStorage just before navigating +// away, then consumes the snapshot on the next mount — but only if the user is +// now actually signed in. +// +// Email-OTP login never redirects (it resolves in-page), so it bypasses this +// machinery entirely and resumes synchronously via AuthModal.onSuccess. +// +// This module holds the three page-agnostic pieces: the login check, a +// quota-safe sessionStorage write (heavy data-URL fields are stripped on +// QuotaExceededError), and the consume-once resume gate. Each page keeps its +// own snapshot shape and restore side effects — only the plumbing is shared. + +import { AUTH_ENABLED } from "@/lib/supabase/config"; +import { createClient as createSupabaseClient } from "@/lib/supabase/client"; + +// True when auth is disabled (self-host with blank Supabase env) or the visitor +// already has a session. Gates any auth-required action (and the resume path). +export async function isAuthed(): Promise { + if (!AUTH_ENABLED) return true; + const sb = createSupabaseClient(); + const { data } = await sb.auth.getUser(); + return !!data.user; +} + +// Write a resume snapshot to sessionStorage with a quota-safe fallback. +// `fallbacks` is an ordered list of progressively-lighter payloads to try if +// the primary write fails (typically QuotaExceededError from a data-URL image). +// Each fallback drops some non-essential heavy field while keeping the data +// needed to resume. A dropped field only affects *future* generation (e.g. the +// painter on later scenes), never the scene being resumed, so degrading is +// graceful. Returns true if any write succeeded. +export function writeResumeSnapshot( + key: string, + primary: T, + fallbacks: readonly T[] = [], +): boolean { + const tryWrite = (candidate: T): boolean => { + try { + sessionStorage.setItem(key, JSON.stringify(candidate)); + return true; + } catch { + return false; // QuotaExceededError (or disabled storage) + } + }; + if (tryWrite(primary)) return true; + for (const fb of fallbacks) { + if (tryWrite(fb)) return true; + } + return false; +} + +// Consume-once resume gate. Returns the parsed snapshot if one exists at `key` +// AND the user is now signed in (so a stale snapshot from a failed/abandoned +// login doesn't resurrect a half-flow). Always removes the entry — either it's +// consumed here, or it's stale and must not linger. Returns null when there's +// nothing to resume, the user isn't signed in, or the payload is corrupt. +export async function consumeResumeSnapshot(key: string): Promise { + const raw = sessionStorage.getItem(key); + if (!raw) return null; + sessionStorage.removeItem(key); + if (!(await isAuthed())) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; // corrupt snapshot — ignore + } +}