From 983434df76bb4ca25014480acaef61e3584bc778 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:09:05 +0000 Subject: [PATCH 01/11] plan 0028: surface setup-history load and action failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release review A3: the history load had no catch, so a failed IndexedDB read left the panel on "Loading…" forever, and a rollback or duplicate that threw was swallowed (the busy flag cleared, nothing said why). The panel now shows an error with a Retry button when the load fails, and a failed rollback/duplicate raises an error toast instead of a silent no-op. Strings added in every locale. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- src/components/drawer/SetupHistoryPanel.tsx | 65 ++++++++++++++++----- src/locales/de/drawer.json | 4 ++ src/locales/en/drawer.json | 4 ++ src/locales/es/drawer.json | 4 ++ src/locales/fr/drawer.json | 4 ++ src/locales/it/drawer.json | 4 ++ src/locales/ja/drawer.json | 4 ++ src/locales/pt-BR/drawer.json | 4 ++ 8 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/components/drawer/SetupHistoryPanel.tsx b/src/components/drawer/SetupHistoryPanel.tsx index f436f9b1..3ff3def5 100644 --- a/src/components/drawer/SetupHistoryPanel.tsx +++ b/src/components/drawer/SetupHistoryPanel.tsx @@ -49,6 +49,9 @@ export function SetupHistoryPanel({ // Revision awaiting rollback confirmation. const [rollbackTarget, setRollbackTarget] = useState(null); const [busy, setBusy] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + // Bumped by the error state's Retry button to re-run the load effect. + const [reloadKey, setReloadKey] = useState(0); const load = useCallback(async () => { // Sweep first so the panel always matches the retention notice it shows. @@ -59,28 +62,47 @@ export function SetupHistoryPanel({ useEffect(() => { let cancelled = false; - load().then(({ revs, m }) => { - if (cancelled) return; - setRevisions(revs); - setMetas(m); - setLoading(false); - }); + setLoading(true); + setLoadFailed(false); + load() + .then(({ revs, m }) => { + if (cancelled) return; + setRevisions(revs); + setMetas(m); + }) + // A failed IndexedDB read must not leave the panel on "Loading…" forever. + .catch(() => { + if (!cancelled) setLoadFailed(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); return () => { cancelled = true; }; - }, [load]); + }, [load, reloadKey]); // Rollback / duplicate both save a setup (which freezes a revision), so the // list is reloaded afterwards to show the result. const runAction = useCallback( - async (action: () => Promise, done: string) => { + async (action: () => Promise, done: string, failed: string) => { setBusy(true); try { - await action(); - const { revs, m } = await load(); - setRevisions(revs); - setMetas(m); + try { + await action(); + } catch { + toast.error(failed); + return; + } toast.success(done); + try { + const { revs, m } = await load(); + setRevisions(revs); + setMetas(m); + setLoadFailed(false); + } catch { + setLoadFailed(true); + } } finally { setBusy(false); } @@ -92,13 +114,21 @@ export function SetupHistoryPanel({ const target = rollbackTarget; if (!target || !onRollback) return; setRollbackTarget(null); - void runAction(() => onRollback(target), t("setupHistory.rollbackDone", { hash: shortRevHash(target.id) })); + void runAction( + () => onRollback(target), + t("setupHistory.rollbackDone", { hash: shortRevHash(target.id) }), + t("setupHistory.rollbackFailed"), + ); }; const duplicate = (revision: SetupRevision) => { if (!onDuplicate) return; const name = t("setupHistory.duplicateName", { name: revision.name }); - void runAction(() => onDuplicate(revision), t("setupHistory.duplicateDone", { name })); + void runAction( + () => onDuplicate(revision), + t("setupHistory.duplicateDone", { name }), + t("setupHistory.duplicateFailed"), + ); }; const history = useMemo( @@ -189,6 +219,13 @@ export function SetupHistoryPanel({
{loading ? (

{t("setupHistory.loading")}

+ ) : loadFailed ? ( +
+

{t("setupHistory.loadFailed")}

+ +
) : history.entries.length === 0 ? (
diff --git a/src/locales/de/drawer.json b/src/locales/de/drawer.json index 9c551bc7..780035e6 100644 --- a/src/locales/de/drawer.json +++ b/src/locales/de/drawer.json @@ -146,6 +146,8 @@ "title": "Setup-Verlauf", "openTitle": "Setup-Verlauf anzeigen", "loading": "Verlauf wird geladen…", + "loadFailed": "Der Verlauf dieses Setups konnte nicht geladen werden.", + "retry": "Erneut versuchen", "empty": "Noch kein Verlauf", "emptyHint": "Weise dieses Setup einer Sitzung zu, um den Verlauf aufzubauen.", "emptyUsedHint": "Noch keine Session hat dieses Setup verwendet. Wechsle zu Alle, um jede gespeicherte Revision zu sehen.", @@ -158,9 +160,11 @@ "rollbackTitle": "Auf dieses Setup zurücksetzen?", "rollbackBody": "{{setup}} wird auf das Setup vom {{date}} (#{{hash}}) zurückgesetzt. Deine aktuellen Änderungen bleiben {{days}} Tage im Verlauf.", "rollbackDone": "Auf #{{hash}} zurückgesetzt", + "rollbackFailed": "Zurücksetzen fehlgeschlagen – dein Setup wurde nicht geändert.", "duplicate": "Duplizieren", "duplicateName": "{{name}} (Kopie)", "duplicateDone": "{{name}} erstellt", + "duplicateFailed": "Diese Revision konnte nicht dupliziert werden.", "allKarts": "Alle Karts", "allCourses": "Alle Strecken", "original": "Original", diff --git a/src/locales/en/drawer.json b/src/locales/en/drawer.json index 3805e000..82f3d826 100644 --- a/src/locales/en/drawer.json +++ b/src/locales/en/drawer.json @@ -145,6 +145,8 @@ "title": "Setup History", "openTitle": "View setup history", "loading": "Loading history…", + "loadFailed": "Couldn't load this setup's history.", + "retry": "Retry", "empty": "No history yet", "emptyHint": "Assign this setup to a session to start building its history.", "emptyUsedHint": "No session has used this setup yet. Switch to All to see every saved revision.", @@ -157,9 +159,11 @@ "rollbackTitle": "Roll back to this setup?", "rollbackBody": "{{setup}} will be reset to the setup that ran on {{date}} (#{{hash}}). Your current edits stay in history for {{days}} days.", "rollbackDone": "Rolled back to #{{hash}}", + "rollbackFailed": "Rollback failed — your setup was not changed.", "duplicate": "Duplicate", "duplicateName": "{{name}} (copy)", "duplicateDone": "Created {{name}}", + "duplicateFailed": "Couldn't duplicate this revision.", "allKarts": "All karts", "allCourses": "All courses", "original": "Original", diff --git a/src/locales/es/drawer.json b/src/locales/es/drawer.json index e4a74898..749fe199 100644 --- a/src/locales/es/drawer.json +++ b/src/locales/es/drawer.json @@ -146,6 +146,8 @@ "title": "Historial de configuración", "openTitle": "Ver historial de configuración", "loading": "Cargando historial…", + "loadFailed": "No se pudo cargar el historial de esta configuración.", + "retry": "Reintentar", "empty": "Aún no hay historial", "emptyHint": "Asigna esta configuración a una sesión para empezar a crear su historial.", "emptyUsedHint": "Ninguna sesión ha usado esta configuración todavía. Cambia a Todas para ver cada revisión guardada.", @@ -158,9 +160,11 @@ "rollbackTitle": "¿Revertir a esta configuración?", "rollbackBody": "{{setup}} volverá a la configuración que se usó el {{date}} (#{{hash}}). Tus cambios actuales permanecen en el historial durante {{days}} días.", "rollbackDone": "Revertido a #{{hash}}", + "rollbackFailed": "No se pudo revertir: tu configuración no se ha modificado.", "duplicate": "Duplicar", "duplicateName": "{{name}} (copia)", "duplicateDone": "Se creó {{name}}", + "duplicateFailed": "No se pudo duplicar esta revisión.", "allKarts": "Todos los karts", "allCourses": "Todos los circuitos", "original": "Original", diff --git a/src/locales/fr/drawer.json b/src/locales/fr/drawer.json index fa78682c..52e5d43d 100644 --- a/src/locales/fr/drawer.json +++ b/src/locales/fr/drawer.json @@ -146,6 +146,8 @@ "title": "Historique des réglages", "openTitle": "Voir l'historique des réglages", "loading": "Chargement de l'historique…", + "loadFailed": "Impossible de charger l'historique de ce réglage.", + "retry": "Réessayer", "empty": "Pas encore d'historique", "emptyHint": "Associez ce réglage à une session pour commencer à construire son historique.", "emptyUsedHint": "Aucune session n’a encore utilisé ce réglage. Passez sur Toutes pour voir chaque révision enregistrée.", @@ -158,9 +160,11 @@ "rollbackTitle": "Restaurer ce réglage ?", "rollbackBody": "{{setup}} sera remis au réglage utilisé le {{date}} (#{{hash}}). Vos modifications actuelles restent dans l’historique pendant {{days}} jours.", "rollbackDone": "Restauré vers #{{hash}}", + "rollbackFailed": "Échec de la restauration — votre réglage n'a pas été modifié.", "duplicate": "Dupliquer", "duplicateName": "{{name}} (copie)", "duplicateDone": "{{name}} créé", + "duplicateFailed": "Impossible de dupliquer cette révision.", "allKarts": "Tous les karts", "allCourses": "Tous les circuits", "original": "Original", diff --git a/src/locales/it/drawer.json b/src/locales/it/drawer.json index 66a0b2f4..b4510d3d 100644 --- a/src/locales/it/drawer.json +++ b/src/locales/it/drawer.json @@ -146,6 +146,8 @@ "title": "Cronologia assetto", "openTitle": "Visualizza cronologia assetto", "loading": "Caricamento cronologia…", + "loadFailed": "Impossibile caricare la cronologia di questo assetto.", + "retry": "Riprova", "empty": "Nessuna cronologia", "emptyHint": "Assegna questo assetto a una sessione per iniziare a creare la sua cronologia.", "emptyUsedHint": "Nessuna sessione ha ancora usato questo assetto. Passa a Tutte per vedere ogni revisione salvata.", @@ -158,9 +160,11 @@ "rollbackTitle": "Ripristinare questo assetto?", "rollbackBody": "{{setup}} tornerà all’assetto usato il {{date}} (#{{hash}}). Le modifiche attuali restano nella cronologia per {{days}} giorni.", "rollbackDone": "Ripristinato a #{{hash}}", + "rollbackFailed": "Ripristino non riuscito: l'assetto non è stato modificato.", "duplicate": "Duplica", "duplicateName": "{{name}} (copia)", "duplicateDone": "{{name}} creato", + "duplicateFailed": "Impossibile duplicare questa revisione.", "allKarts": "Tutti i kart", "allCourses": "Tutti i circuiti", "original": "Originale", diff --git a/src/locales/ja/drawer.json b/src/locales/ja/drawer.json index fee6f8d6..3f53440f 100644 --- a/src/locales/ja/drawer.json +++ b/src/locales/ja/drawer.json @@ -146,6 +146,8 @@ "title": "セットアップ履歴", "openTitle": "セットアップ履歴を表示", "loading": "履歴を読み込み中…", + "loadFailed": "このセットアップの履歴を読み込めませんでした。", + "retry": "再試行", "empty": "まだ履歴がありません", "emptyHint": "このセットアップをセッションに割り当てると履歴の作成が始まります。", "emptyUsedHint": "このセットアップを使用したセッションはまだありません。「すべて」に切り替えると保存済みのすべてのリビジョンを表示できます。", @@ -158,9 +160,11 @@ "rollbackTitle": "このセットアップにロールバックしますか?", "rollbackBody": "{{setup}} は {{date}} に使用したセットアップ(#{{hash}})に戻ります。現在の編集内容は {{days}} 日間履歴に残ります。", "rollbackDone": "#{{hash}} にロールバックしました", + "rollbackFailed": "ロールバックに失敗しました。セットアップは変更されていません。", "duplicate": "複製", "duplicateName": "{{name}}(コピー)", "duplicateDone": "{{name}} を作成しました", + "duplicateFailed": "このリビジョンを複製できませんでした。", "allKarts": "すべてのカート", "allCourses": "すべてのコース", "original": "オリジナル", diff --git a/src/locales/pt-BR/drawer.json b/src/locales/pt-BR/drawer.json index 6c8b99b6..33ea8138 100644 --- a/src/locales/pt-BR/drawer.json +++ b/src/locales/pt-BR/drawer.json @@ -146,6 +146,8 @@ "title": "Histórico de configuração", "openTitle": "Ver histórico de configuração", "loading": "Carregando histórico…", + "loadFailed": "Não foi possível carregar o histórico desta configuração.", + "retry": "Tentar novamente", "empty": "Ainda não há histórico", "emptyHint": "Atribua esta configuração a uma sessão para começar a criar o histórico.", "emptyUsedHint": "Nenhuma sessão usou esta configuração ainda. Mude para Todas para ver cada revisão salva.", @@ -158,9 +160,11 @@ "rollbackTitle": "Reverter para esta configuração?", "rollbackBody": "{{setup}} será redefinida para a configuração usada em {{date}} (#{{hash}}). Suas edições atuais permanecem no histórico por {{days}} dias.", "rollbackDone": "Revertido para #{{hash}}", + "rollbackFailed": "Falha ao reverter — sua configuração não foi alterada.", "duplicate": "Duplicar", "duplicateName": "{{name}} (cópia)", "duplicateDone": "{{name}} criado", + "duplicateFailed": "Não foi possível duplicar esta revisão.", "allKarts": "Todos os karts", "allCourses": "Todas as pistas", "original": "Original", From 8bb7b7fa14e245eb5fac7685ac25153bedc6e8a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:09:05 +0000 Subject: [PATCH 02/11] plan 0029: skip malformed GPMF payloads, cap hostile MP4 sample counts Release review A4 + SEC-1. A4: an unknown TYPE character in one GPS9 stream threw out of extractGpsPayload and aborted the whole GoPro import. A camera writes one payload per second, so a single corrupt one should cost one second, not the session. Each stream decode is now isolated (a bad GPS9 can still fall back to GPS5 in the same payload), and readComplex rejects a TYPE wider than its record instead of reading into the next one. SEC-1: a uniform-size stsz box is just a size and a uint32 count, so a hostile file could ask new Array() for ~4 billion entries and OOM the tab (the regression test OOMs the worker without the fix). Every sample occupies `size` bytes of the file, so the count is capped at fileSize / size. The other sample tables were already bounded by their box length. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- src/lib/gopro/goproTelemetry.test.ts | 12 +++++++++- src/lib/gopro/gpmf.test.ts | 26 ++++++++++++++++++++ src/lib/gopro/gpmf.ts | 36 +++++++++++++++++++++++----- src/lib/gopro/mp4Boxes.test.ts | 9 +++++++ src/lib/gopro/mp4Boxes.ts | 17 +++++++++---- src/lib/gopro/testFixtures.ts | 6 ++++- 6 files changed, 93 insertions(+), 13 deletions(-) diff --git a/src/lib/gopro/goproTelemetry.test.ts b/src/lib/gopro/goproTelemetry.test.ts index f49501eb..c810202a 100644 --- a/src/lib/gopro/goproTelemetry.test.ts +++ b/src/lib/gopro/goproTelemetry.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { buildGoProSession, rowsFromPayloads, type GoProPayload } from "./goproTelemetry"; -import { acclOnlyPayload, gps5Payload, gps9Payload } from "./testFixtures"; +import { acclOnlyPayload, gpmfComplex, gpmfNested, gpmfString, gps5Payload, gps9Payload } from "./testFixtures"; import { parseDoveFile } from "@/lib/doveParser"; const buf = (bytes: Uint8Array) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); @@ -32,6 +32,16 @@ describe("rowsFromPayloads", () => { expect(rows.map((r) => r.tSec)).toEqual([5, 5.2]); }); + it("skips a malformed payload and keeps importing the rest", () => { + const bad = gpmfNested("DEVC", [ + gpmfNested("STRM", [gpmfString("TYPE", "lllllllSx"), gpmfComplex("GPS9", "lllllllSS", [[1, 2, 3, 4, 5, 6, 7, 8, 3]])]), + ]); + const good = gps5Payload([{ lat: 33.5, lon: -117.7, alt: 0, speed2d: 1 }], { gpsu: "240615143025.000" }); + const rows = rowsFromPayloads([payload(bad, 0), payload(good, 1)]); + expect(rows).toHaveLength(1); + expect(rows[0].tSec).toBe(1); + }); + it("rejects zero/out-of-range coordinates and skips payloads without GPS", () => { const rows = rowsFromPayloads([ payload(gps5Payload([{ lat: 0, lon: 0, alt: 0, speed2d: 0 }, { lat: 95, lon: 10, alt: 0, speed2d: 0 }, { lat: 33.5, lon: -117.7, alt: 0, speed2d: 1 }]), 0), diff --git a/src/lib/gopro/gpmf.test.ts b/src/lib/gopro/gpmf.test.ts index 6bc6d79c..795169dc 100644 --- a/src/lib/gopro/gpmf.test.ts +++ b/src/lib/gopro/gpmf.test.ts @@ -55,6 +55,11 @@ describe("value readers", () => { const bytes = gpmfComplex("GPS9", "l", [[1]]); expect(() => readComplex(view(bytes), parseGpmf(view(bytes))[0], "x")).toThrow(/unsupported/); }); + + it("rejects a TYPE wider than the record instead of reading the next one", () => { + const bytes = gpmfComplex("GPS9", "l", [[1], [2]]); + expect(() => readComplex(view(bytes), parseGpmf(view(bytes))[0], "ll")).toThrow(/exceeds/); + }); }); describe("parseGpsu", () => { @@ -107,6 +112,27 @@ describe("extractGpsPayload", () => { expect(gps?.utcMs).toBe(utc); }); + it("returns null (skip) for a malformed GPS9 stream instead of throwing", () => { + // Regression (A4): an unknown TYPE char used to throw out of the whole import. + const bytes = gpmfNested("DEVC", [ + gpmfNested("STRM", [gpmfString("TYPE", "lllllllSx"), gpmfComplex("GPS9", "lllllllSS", [[1, 2, 3, 4, 5, 6, 7, 8, 3]])]), + ]); + expect(extractGpsPayload(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))).toBeNull(); + }); + + it("falls back to GPS5 when the payload's GPS9 stream is malformed", () => { + const gps5 = gps5Payload([{ lat: 33.5, lon: -117.7, alt: 1, speed2d: 1 }]); + const bytes = concat([ + gpmfNested("DEVC", [ + gpmfNested("STRM", [gpmfString("TYPE", "x"), gpmfComplex("GPS9", "l", [[1]])]), + ]), + gps5, + ]); + const gps = extractGpsPayload(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + expect(gps?.source).toBe("GPS5"); + expect(gps?.fixes[0].lat).toBeCloseTo(33.5, 6); + }); + it("returns null for a payload without a GPS stream", () => { const bytes = acclOnlyPayload(); expect(extractGpsPayload(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))).toBeNull(); diff --git a/src/lib/gopro/gpmf.ts b/src/lib/gopro/gpmf.ts index 8e8c6ee6..a8d3a154 100644 --- a/src/lib/gopro/gpmf.ts +++ b/src/lib/gopro/gpmf.ts @@ -104,15 +104,24 @@ export function readString(view: DataView, item: GpmfItem): string { */ export function readComplex(view: DataView, item: GpmfItem, typeString: string): number[][] { const fields = typeString.split("").filter((c) => c !== "\0"); + let recordSize = 0; + for (const typeChar of fields) { + const size = TYPE_SIZES[typeChar]; + if (!size) throw new Error(`GPMF: unsupported TYPE field '${typeChar}'`); + recordSize += size; + } + // A TYPE wider than the record would read into the next record (or past the + // payload), so treat the mismatch as corruption rather than decode garbage. + if (recordSize > item.structSize) { + throw new Error(`GPMF: TYPE '${typeString}' (${recordSize} B) exceeds the ${item.structSize} B record`); + } const rows: number[][] = []; for (let r = 0; r < item.repeat; r++) { let offset = item.dataOffset + r * item.structSize; const row: number[] = []; for (const typeChar of fields) { - const size = TYPE_SIZES[typeChar]; - if (!size) throw new Error(`GPMF: unsupported TYPE field '${typeChar}'`); row.push(readScalar(view, offset, typeChar)); - offset += size; + offset += TYPE_SIZES[typeChar]; } rows.push(row); } @@ -221,7 +230,8 @@ function decodeGps5(view: DataView, stream: Map): GpmfGpsPaylo /** * Pull the GPS stream out of one `gpmd` payload. Prefers GPS9 (HERO11+, - * per-sample time/DOP/fix) over GPS5. Null when the payload has no GPS stream. + * per-sample time/DOP/fix) over GPS5. Null when the payload has no GPS stream + * or its GPS stream is malformed (the caller skips the payload). */ export function extractGpsPayload(buffer: ArrayBuffer): GpmfGpsPayload | null { const view = new DataView(buffer); @@ -233,12 +243,26 @@ export function extractGpsPayload(buffer: ArrayBuffer): GpmfGpsPayload | null { } } for (const stream of streams) { - const gps9 = decodeGps9(view, stream); + const gps9 = decodeOrSkip(() => decodeGps9(view, stream)); if (gps9) return gps9; } for (const stream of streams) { - const gps5 = decodeGps5(view, stream); + const gps5 = decodeOrSkip(() => decodeGps5(view, stream)); if (gps5) return gps5; } return null; } + +/** + * One corrupt stream (an unknown TYPE character, a record that runs past the + * payload) must cost that payload's GPS, not the whole import: a GoPro file + * carries one payload per second, so skipping a bad one leaves a one-second + * gap while throwing would reject an hour of otherwise good telemetry. + */ +function decodeOrSkip(decode: () => GpmfGpsPayload | null): GpmfGpsPayload | null { + try { + return decode(); + } catch { + return null; + } +} diff --git a/src/lib/gopro/mp4Boxes.test.ts b/src/lib/gopro/mp4Boxes.test.ts index 2ec0160d..2a2f8438 100644 --- a/src/lib/gopro/mp4Boxes.test.ts +++ b/src/lib/gopro/mp4Boxes.test.ts @@ -46,6 +46,15 @@ describe("readGpmdTrack", () => { expect(await readGpmdTrack(bufferByteSource(new ArrayBuffer(3)))).toBeNull(); }); + it("caps a hostile uniform stsz count against the file size instead of allocating it", async () => { + // Regression (SEC-1): a ~4-billion-entry uniform stsz used to hang/OOM the tab. + const file = buildGoProMp4({ payloads, uniformStsz: { size: 1, count: 0xffff_fffe } }); + const track = await readGpmdTrack(bufferByteSource(file)); + // Three one-sample chunks bound what resolves; the cap bounds what is allocated. + expect(track!.samples).toHaveLength(3); + expect(track!.samples.every((s) => s.size === 1)).toBe(true); + }); + it("leaves the creation time undefined when the camera wrote none", async () => { const track = await readGpmdTrack(bufferByteSource(buildGoProMp4({ payloads }))); expect(track!.creationEpochMs).toBeUndefined(); diff --git a/src/lib/gopro/mp4Boxes.ts b/src/lib/gopro/mp4Boxes.ts index 5ed4c6a1..8694b66e 100644 --- a/src/lib/gopro/mp4Boxes.ts +++ b/src/lib/gopro/mp4Boxes.ts @@ -178,10 +178,17 @@ function readTable(view: DataView, box: BoxRef, width: 1 | 2): number[] | [numbe return out as number[] | [number, number][]; } -function readStsz(view: DataView, box: BoxRef): number[] { +/** + * `stsz` sample sizes. The explicit-size form is bounded by the box itself, but + * the uniform form is just a size and a uint32 count — a hostile file can ask + * for ~4 billion entries, which hangs or OOMs the tab. Every sample occupies + * `uniform` bytes of the file, so no honest table can list more than + * `fileSize / uniform` of them; cap the count there. + */ +function readStsz(view: DataView, box: BoxRef, fileSize: number): number[] { const uniform = view.getUint32(box.bodyStart + 4); const count = view.getUint32(box.bodyStart + 8); - if (uniform !== 0) return new Array(count).fill(uniform); + if (uniform !== 0) return new Array(Math.min(count, Math.floor(fileSize / uniform))).fill(uniform); const sizes: number[] = []; let offset = box.bodyStart + 12; for (let i = 0; i < count && offset + 4 <= box.end; i++, offset += 4) { @@ -210,14 +217,14 @@ function readCo64(view: DataView, box: BoxRef): number[] { return offsets; } -function readStbl(view: DataView, stbl: BoxRef): { format: string | null; table: SampleTable } { +function readStbl(view: DataView, stbl: BoxRef, fileSize: number): { format: string | null; table: SampleTable } { let format: string | null = null; const table: SampleTable = { sizes: [], chunkOffsets: [], chunkRuns: [], timeRuns: [] }; for (const box of children(view, stbl.bodyStart, stbl.end)) { switch (box.type) { case "stsd": format = readStsd(view, box); break; case "stts": table.timeRuns = readTable(view, box, 2) as [number, number][]; break; - case "stsz": table.sizes = readStsz(view, box); break; + case "stsz": table.sizes = readStsz(view, box, fileSize); break; case "stsc": table.chunkRuns = readStsc(view, box); break; case "stco": table.chunkOffsets = readTable(view, box, 1) as number[]; break; case "co64": table.chunkOffsets = readCo64(view, box); break; @@ -312,7 +319,7 @@ export async function readGpmdTrack(src: ByteSource): Promise const minf = findChild(view, mdia.bodyStart, mdia.end, "minf"); const stbl = minf && findChild(view, minf.bodyStart, minf.end, "stbl"); if (!stbl) continue; - const { format, table } = readStbl(view, stbl); + const { format, table } = readStbl(view, stbl, src.size); if (format !== "gpmd") continue; const mdhd = findChild(view, mdia.bodyStart, mdia.end, "mdhd"); const timescale = mdhd ? readMdhdTimescale(view, mdhd) : 0; diff --git a/src/lib/gopro/testFixtures.ts b/src/lib/gopro/testFixtures.ts index 89f9fc94..550587aa 100644 --- a/src/lib/gopro/testFixtures.ts +++ b/src/lib/gopro/testFixtures.ts @@ -185,6 +185,8 @@ export interface Mp4Options { withVideoTrack?: boolean; /** Use a 64-bit `co64` chunk table. */ co64?: boolean; + /** Write a uniform-size `stsz` (size + count) instead of a per-sample list. */ + uniformStsz?: { size: number; count: number }; } /** @@ -211,7 +213,9 @@ export function buildGoProMp4(opts: Mp4Options): ArrayBuffer { for (const p of opts.payloads) { offsets.push(cursor); cursor += p.byteLength; } const stts = fullBox("stts", u32(durations.length), ...durations.map((d) => u32(1, d))); - const stsz = fullBox("stsz", u32(0), u32(opts.payloads.length), ...opts.payloads.map((p) => u32(p.byteLength))); + const stsz = opts.uniformStsz + ? fullBox("stsz", u32(opts.uniformStsz.size), u32(opts.uniformStsz.count)) + : fullBox("stsz", u32(0), u32(opts.payloads.length), ...opts.payloads.map((p) => u32(p.byteLength))); const stsc = fullBox("stsc", u32(1), u32(1, 1, 1)); const stco = opts.co64 ? fullBox("co64", u32(offsets.length), ...offsets.map((o) => u32(0, o))) From bbef06385eae295daba635c77c6fc6da1b7f3431 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:09:05 +0000 Subject: [PATCH 03/11] plan 0025: a superseded camera player can't close the newer stream Release review B6: the shell runs one native player and insta360_player_close carries no player id, so an earlier NativePlayerElement closing late (its close() after a newer one opened, or an open that resolved after it was closed) killed whichever stream was live. Elements now record which one last opened the player and only that one may release it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- src/lib/insta360/nativePlayer.test.ts | 39 +++++++++++++++++++++++++++ src/lib/insta360/nativePlayer.ts | 25 +++++++++++++++-- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/lib/insta360/nativePlayer.test.ts b/src/lib/insta360/nativePlayer.test.ts index da2e9355..f25cf3dc 100644 --- a/src/lib/insta360/nativePlayer.test.ts +++ b/src/lib/insta360/nativePlayer.test.ts @@ -198,4 +198,43 @@ describe("NativePlayerElement", () => { await expect(opening).rejects.toThrow(/closed/); expect(close).toHaveBeenCalled(); }); + + it("a superseded player's late close does not kill the newer stream", async () => { + // Regression (B6): the shell's close carries no player id. + const close = vi.fn(async () => {}); + const info: Insta360PlayerInfo = { streamUrl: "u", width: 640, height: 360, durationMs: 1, is360: false }; + const make = () => + new NativePlayerElement(file, { + width: 640, height: 360, open: async () => info, control: async () => {}, close, setView: async (p) => p, + }); + const first = make(); + await first.open(); + const second = make(); + await second.open(); + first.close(); + expect(close).not.toHaveBeenCalled(); + second.close(); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("an open that resolves after a newer player opened leaves the newer one alone", async () => { + const close = vi.fn(async () => {}); + let resolveFirst: ((i: Insta360PlayerInfo) => void) | null = null; + const info: Insta360PlayerInfo = { streamUrl: "u", width: 640, height: 360, durationMs: 1, is360: false }; + const first = new NativePlayerElement(file, { + width: 640, height: 360, + open: () => new Promise((res) => { resolveFirst = res; }), + control: async () => {}, close, setView: async (p) => p, + }); + const second = new NativePlayerElement(file, { + width: 640, height: 360, open: async () => info, control: async () => {}, close, setView: async (p) => p, + }); + const opening = first.open(); + first.close(); + await second.open(); + resolveFirst!(info); + await expect(opening).rejects.toThrow(/closed/); + expect(close).not.toHaveBeenCalled(); + expect(second.streamUrl).toBe("u"); + }); }); diff --git a/src/lib/insta360/nativePlayer.ts b/src/lib/insta360/nativePlayer.ts index 2829563d..629c72b6 100644 --- a/src/lib/insta360/nativePlayer.ts +++ b/src/lib/insta360/nativePlayer.ts @@ -64,6 +64,17 @@ export interface NativePlayerOptions { const SEEK_WATCHDOG_MS = 600; +/** + * The shell runs ONE native player and `insta360_player_close` carries no + * player id, so a superseded element's late close (its `close()` after a newer + * element opened, or an open that resolved after it was closed) would kill + * whichever stream is live now. Track which element last opened the player + * and let only that one release it. Keyed by the close command so injected + * test doubles each get their own slot; in the app every element shares the + * real one. + */ +const playerOwners = new WeakMap(); + /** * One native player session over one camera recording. `open()` starts the * stream; `close()` (or opening another) stops it. Extends `EventTarget` so @@ -189,9 +200,11 @@ export class NativePlayerElement extends EventTarget implements VideoSurface { quality: this.opts.quality, muted: this.opts.muted, }; + // Opening replaces any earlier player in the shell, so ownership moves now. + playerOwners.set(this.ipc.close, this); const info = await this.ipc.open(request, (e) => this.onEvent(e)); if (this.closed) { - void this.ipc.close(); + this.releaseNativePlayer(); throw new Error("player closed while opening"); } this.info = info; @@ -207,7 +220,7 @@ export class NativePlayerElement extends EventTarget implements VideoSurface { if (this.closed) return; this.closed = true; this.clearSeekTimer(); - if (this.info) void this.ipc.close(); + if (this.info) this.releaseNativePlayer(); this.info = null; this.clock.setPlaying(false, this.now()); } @@ -222,6 +235,13 @@ export class NativePlayerElement extends EventTarget implements VideoSurface { // ── internals ─────────────────────────────────────────────────────────── + /** Close the shell's player — only while this element is the one that owns it. */ + private releaseNativePlayer(): void { + if (playerOwners.get(this.ipc.close) !== this) return; + playerOwners.delete(this.ipc.close); + void this.ipc.close(); + } + private seekTo(sec: number, precise: boolean): void { if (this.closed || !this.info) return; const positionMs = Math.max(0, Math.round(sec * 1000)); @@ -281,6 +301,7 @@ export class NativePlayerElement extends EventTarget implements VideoSurface { this.dispatchEvent(new Event("error")); break; case "closed": + if (playerOwners.get(this.ipc.close) === this) playerOwners.delete(this.ipc.close); this.closed = true; this.info = null; this.clearSeekTimer(); From 52b6ae78e2bfae326ef58380c47e3b256152867e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:09:21 +0000 Subject: [PATCH 04/11] plans 0024/0025: one tested "native feature unavailable" classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release review T2: the check that decides "this shell can't do it, fall back quietly" was copied into nativeVideoExport.ts, nativeVideoStore.ts and insta360/ipc.ts, untested, and matched any message containing "unknown", "not found" or "not allowed" — so a real failure such as "file not found" or "unknown error" could be mistaken for an old shell and silently take the fallback path. It now lives once in lib/nativeUnavailable.ts and only accepts the `unsupported:` sentinel prefix or Tauri's own missing-command / ACL rejection shapes. The Insta360 copy had no callers and is removed (insta360SdkInfo already treats any error as unavailable). nativeVideoExport's exports and its handling of every real shell error string are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- src/lib/insta360/ipc.ts | 10 ++-------- src/lib/nativeUnavailable.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/nativeUnavailable.ts | 23 +++++++++++++++++++++++ src/lib/nativeVideoExport.ts | 12 +++--------- src/lib/nativeVideoStore.ts | 13 ++++--------- 5 files changed, 62 insertions(+), 26 deletions(-) create mode 100644 src/lib/nativeUnavailable.test.ts create mode 100644 src/lib/nativeUnavailable.ts diff --git a/src/lib/insta360/ipc.ts b/src/lib/insta360/ipc.ts index b5c01a97..5bffcdaf 100644 --- a/src/lib/insta360/ipc.ts +++ b/src/lib/insta360/ipc.ts @@ -3,8 +3,8 @@ * lazy loader in `loggers/native/ipc.ts` so `@tauri-apps/api` stays out of * the web bundle. Errors are plain strings; an `unsupported:` prefix means * this shell can't talk to a camera (desktop, or an Android build without - * the SDK) — `isInsta360Unavailable` folds a shell that predates these - * commands into the same answer. + * the SDK) — `isNativeFeatureUnavailable` (`lib/nativeUnavailable.ts`) folds + * a shell that predates these commands into the same answer. */ import { api } from "@/lib/loggers/native/ipc"; @@ -23,12 +23,6 @@ import type { ViewPose, } from "./types"; -/** The stub sentinel, or a shell that doesn't know the command at all. */ -export function isInsta360Unavailable(err: unknown): boolean { - const msg = String(err); - return msg.startsWith("unsupported:") || /unknown|not found|not allowed/i.test(msg); -} - /** * Whether this shell can talk to an Insta360 camera. Resolves `false` off * native, on the stubs, and on a shell that predates the feature — never diff --git a/src/lib/nativeUnavailable.test.ts b/src/lib/nativeUnavailable.test.ts new file mode 100644 index 00000000..05ea6a63 --- /dev/null +++ b/src/lib/nativeUnavailable.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { isNativeFeatureUnavailable } from "./nativeUnavailable"; + +describe("isNativeFeatureUnavailable", () => { + it("accepts the shell's unsupported: sentinel, as a string or an Error", () => { + expect(isNativeFeatureUnavailable("unsupported: native video export is not supported on this platform yet")).toBe(true); + expect(isNativeFeatureUnavailable(new Error("unsupported: no Insta360 SDK in this build"))).toBe(true); + }); + + it("accepts Tauri's missing-command and ACL rejections from an older shell", () => { + expect(isNativeFeatureUnavailable("Command video_export_begin not found")).toBe(true); + expect(isNativeFeatureUnavailable(new Error("Command video_store_list not found"))).toBe(true); + expect(isNativeFeatureUnavailable("unknown command insta360_sdk_info")).toBe(true); + expect(isNativeFeatureUnavailable("insta360_player_open not allowed. Permissions associated with this command: none")).toBe(true); + }); + + it("rejects errors from a command that ran — those must be reported", () => { + for (const msg of [ + "no stored video for key abc", + "file not found", + "unknown error", + "camera not found on the network", + "device unreachable: not found", + "storage write is not allowed while recording", + "transcode failed: unsupported: codec", // the sentinel only counts as a prefix + ]) { + expect(isNativeFeatureUnavailable(msg), msg).toBe(false); + } + }); +}); diff --git a/src/lib/nativeUnavailable.ts b/src/lib/nativeUnavailable.ts new file mode 100644 index 00000000..9129d61d --- /dev/null +++ b/src/lib/nativeUnavailable.ts @@ -0,0 +1,23 @@ +/** + * "This shell can't do that" — the one classifier every optional native + * (LapWing shell) feature uses to decide between falling back quietly and + * surfacing an error. + * + * Two shapes mean unavailable: + * - the shell's own sentinel: a rejection prefixed `unsupported:` (a desktop + * stub, or an Android build without the feature's SDK); + * - Tauri refusing the invoke because the command doesn't exist in this + * (older) shell — `Command not found`, an `unknown command`, or an + * ACL denial (` not allowed…`). + * + * Deliberately narrow: a command that ran and failed (`no stored video for + * key …`, `file not found`, `unknown error`) is a real error the caller must + * report, not a reason to silently take another path. + */ + +const MISSING_COMMAND = /\bcommand\s+\S+\s+not\s+found\b|\bunknown\s+command\b|^\S+\s+not\s+allowed\b/i; + +export function isNativeFeatureUnavailable(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return msg.startsWith("unsupported:") || MISSING_COMMAND.test(msg); +} diff --git a/src/lib/nativeVideoExport.ts b/src/lib/nativeVideoExport.ts index d32b29e1..8a692ebf 100644 --- a/src/lib/nativeVideoExport.ts +++ b/src/lib/nativeVideoExport.ts @@ -25,6 +25,7 @@ import { api } from "@/lib/loggers/native/ipc"; import { blobToBase64, NATIVE_CHUNK_BYTES } from "@/lib/nativeBytes"; import { isNativeApp } from "@/lib/platform"; +import { isNativeFeatureUnavailable } from "@/lib/nativeUnavailable"; import { renderOverlaysToCanvas, DEFAULT_OVERLAY_LABELS, @@ -47,13 +48,6 @@ export interface NativeExportController { cancel: () => void; } -/** True for the errors that mean "this shell can't do native export" — the - * desktop stub's sentinel, or a shell predating the command. */ -function isUnavailable(err: unknown): boolean { - const msg = String(err); - return msg.startsWith("unsupported:") || /unknown|not found|not allowed/i.test(msg); -} - /** * Try to run the export natively. Resolves `null` when the native pipeline is * unavailable (caller falls back to the in-WebView exporter — nothing has been @@ -113,12 +107,12 @@ export async function startNativeVideoExport( params: useStoredSource ? { ...baseParams, sourceKey: source.nativeSourceKey } : baseParams, }); } catch (err) { - if (!useStoredSource || isUnavailable(err)) throw err; + if (!useStoredSource || isNativeFeatureUnavailable(err)) throw err; useStoredSource = false; jobId = await invoke("video_export_begin", { params: baseParams }); } } catch (err) { - if (isUnavailable(err)) return null; + if (isNativeFeatureUnavailable(err)) return null; callbacks.onError(String(err)); return { cancel: () => {} }; } diff --git a/src/lib/nativeVideoStore.ts b/src/lib/nativeVideoStore.ts index ef4eabbc..83332e8b 100644 --- a/src/lib/nativeVideoStore.ts +++ b/src/lib/nativeVideoStore.ts @@ -25,6 +25,7 @@ import { api } from "@/lib/loggers/native/ipc"; import { blobToBase64, NATIVE_CHUNK_BYTES } from "@/lib/nativeBytes"; import { isNativeApp } from "@/lib/platform"; +import { isNativeFeatureUnavailable } from "@/lib/nativeUnavailable"; export interface NativeStoredVideo { key: string; @@ -69,12 +70,6 @@ function announceStoreChange(removedKeys: string[] | null): void { ); } -/** The desktop stub's sentinel, or a shell predating the store commands. */ -function isUnavailable(err: unknown): boolean { - const msg = String(err); - return msg.startsWith("unsupported:") || /unknown|not found|not allowed/i.test(msg); -} - /** The session's remembered video, if the shell has one. */ export async function getNativeStoredVideo(sessionFileName: string): Promise { if (!isNativeApp()) return null; @@ -84,7 +79,7 @@ export async function getNativeStoredVideo(sessionFileName: string): Promise("video_store_begin", { sessionFileName, fileName: file.name }); } catch (err) { - if (isUnavailable(err)) return null; + if (isNativeFeatureUnavailable(err)) return null; throw err; } for (let offset = 0; offset < file.size; offset += NATIVE_CHUNK_BYTES) { @@ -139,7 +134,7 @@ export async function listNativeStoredVideos(): Promise("video_store_list"); } catch (err) { - if (!isUnavailable(err)) console.warn("Native video store listing failed:", err); + if (!isNativeFeatureUnavailable(err)) console.warn("Native video store listing failed:", err); return null; } } From 15cb7071b90f2abc25ff0a351cef2717d9947e35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:09:38 +0000 Subject: [PATCH 05/11] plan 0027: derive the deferred-asset route from DEFERRED_ASSET_DIRS Release review T3: the runtime-cache route matched a hand-inlined /^\/(?:samples|loggers)\// because workbox-build serializes the urlPattern callback by its source text, so a closure over the constant would be empty in the worker. Adding a directory to the list without editing the regex would precache-exclude it and then never cache it at runtime. The list moves to scripts/deferredAssets.ts with a builder that derives the regex from it and splices it into the matcher's source; both are unit-tested, including the toString round-trip workbox does. The generated service worker route is byte-identical. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- docs/plans/0027-precache-resilience.md | 6 ++-- scripts/deferredAssets.test.ts | 42 ++++++++++++++++++++++++++ scripts/deferredAssets.ts | 33 ++++++++++++++++++++ vite.config.ts | 19 ++++++------ 4 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 scripts/deferredAssets.test.ts create mode 100644 scripts/deferredAssets.ts diff --git a/docs/plans/0027-precache-resilience.md b/docs/plans/0027-precache-resilience.md index 2af9575a..1d61fcfb 100644 --- a/docs/plans/0027-precache-resilience.md +++ b/docs/plans/0027-precache-resilience.md @@ -50,8 +50,10 @@ service worker at all. ### Keep install small; defer the heavy extras -`DEFERRED_ASSET_DIRS` in `vite.config.ts` (`samples/`, `loggers/`) is the single -source of truth. It feeds three things: +`DEFERRED_ASSET_DIRS` (`samples/`, `loggers/`) is the single source of truth — +it lives in `scripts/deferredAssets.ts` (moved out of `vite.config.ts` after the +4.2.0 release review so the runtime-cache route, previously a hand-copied regex, +could be derived from it and unit-tested). It feeds three things: 1. `globIgnores` — those directories stay out of the install-blocking precache. 2. A `CacheFirst` runtime-caching route (`app-deferred-assets`), so once present diff --git a/scripts/deferredAssets.test.ts b/scripts/deferredAssets.test.ts new file mode 100644 index 00000000..d8dbb826 --- /dev/null +++ b/scripts/deferredAssets.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { DEFERRED_ASSET_DIRS, deferredAssetPathPattern, sameOriginPathMatcher } from "./deferredAssets"; + +const at = (path: string) => new URL(path, "https://lapwingdata.com"); + +describe("deferredAssetPathPattern", () => { + it("derives the route test from the directory list", () => { + const re = deferredAssetPathPattern(DEFERRED_ASSET_DIRS); + expect(re.source).toBe("^\\/(?:samples|loggers)\\/"); + for (const dir of DEFERRED_ASSET_DIRS) expect(re.test(`/${dir}/file.bin`)).toBe(true); + }); + + it("matches only whole top-level directories", () => { + const re = deferredAssetPathPattern(DEFERRED_ASSET_DIRS); + expect(re.test("/samplesX/file")).toBe(false); + expect(re.test("/s/samples/file")).toBe(false); + expect(re.test("/samples")).toBe(false); + }); + + it("escapes regex metacharacters in directory names", () => { + const re = deferredAssetPathPattern(["a.b"]); + expect(re.test("/a.b/x")).toBe(true); + expect(re.test("/aXb/x")).toBe(false); + }); +}); + +describe("sameOriginPathMatcher", () => { + const matcher = sameOriginPathMatcher(deferredAssetPathPattern(DEFERRED_ASSET_DIRS)); + + it("matches same-origin deferred paths only", () => { + expect(matcher({ url: at("/loggers/fledgling.webp"), sameOrigin: true })).toBe(true); + expect(matcher({ url: at("/loggers/fledgling.webp"), sameOrigin: false })).toBe(false); + expect(matcher({ url: at("/index.html"), sameOrigin: true })).toBe(false); + }); + + it("keeps working after the toString round-trip workbox-build does", () => { + // The worker gets the function's source text, not the closure. + const revived = new Function(`return (${matcher.toString()});`)() as typeof matcher; + expect(revived({ url: at("/samples/okc.dovex"), sameOrigin: true })).toBe(true); + expect(revived({ url: at("/sim/birdseye-sim.mjs"), sameOrigin: true })).toBe(false); + }); +}); diff --git a/scripts/deferredAssets.ts b/scripts/deferredAssets.ts new file mode 100644 index 00000000..0e487e0b --- /dev/null +++ b/scripts/deferredAssets.ts @@ -0,0 +1,33 @@ +// Build-time helpers for the offline "deferred asset" directories (plan 0027): +// the big public/ folders held out of the service-worker precache and runtime- +// cached instead. `vite.config.ts` feeds the one list below into the precache +// `globIgnores`, the emitted manifest AND the runtime-cache route, so adding a +// directory can never leave the three disagreeing. +// +// Pure build tooling (Node, runs in `vite build`), never shipped to the client. + +/** `public/` subdirectories served from the runtime cache instead of the precache. */ +export const DEFERRED_ASSET_DIRS = ["samples", "loggers"] as const; + +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); + +/** Pathname test for "is this under one of `dirs`" — e.g. `/^\/(?:samples|loggers)\//`. */ +export function deferredAssetPathPattern(dirs: readonly string[]): RegExp { + return new RegExp(`^\\/(?:${dirs.map(escapeRegExp).join("|")})\\/`); +} + +export type SameOriginRouteMatcher = (ctx: { url: URL; sameOrigin: boolean }) => boolean; + +/** + * A Workbox `urlPattern` callback matching same-origin requests whose pathname + * fits `pattern`. workbox-build serializes the callback into the generated + * worker with `Function.prototype.toString`, where nothing from this module's + * scope survives — so the regex is spliced into the function's SOURCE as a + * literal instead of being captured by a closure. + */ +export function sameOriginPathMatcher(pattern: RegExp): SameOriginRouteMatcher { + return new Function( + "{ url, sameOrigin }", + `return sameOrigin && ${pattern.toString()}.test(url.pathname);`, + ) as SameOriginRouteMatcher; +} diff --git a/vite.config.ts b/vite.config.ts index 3350785f..29433171 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import fs from "fs"; import { execSync } from "child_process"; import { VitePWA } from "vite-plugin-pwa"; import { resolveBranchBackend } from "./scripts/resolveSupabaseBranch"; +import { DEFERRED_ASSET_DIRS, deferredAssetPathPattern, sameOriginPathMatcher } from "./scripts/deferredAssets"; // Build-time version metadata for the footer "what changed" stamp. The app // version comes from package.json; the commit hash + build date are baked in at @@ -97,9 +98,8 @@ function externalPluginsLoader(candidates: string[]): Plugin { // boot: they are runtime-cached instead (see `runtimeCaching` below) and warmed // in the background once the worker is active (src/lib/offlineWarmup.ts), so // they still work offline without being able to take the whole install down. -// One source of truth — the globs feed `globIgnores`, the runtime-cache route -// and the emitted manifest alike. -const DEFERRED_ASSET_DIRS = ["samples", "loggers"] as const; +// One source of truth — DEFERRED_ASSET_DIRS (scripts/deferredAssets.ts) feeds +// `globIgnores`, the runtime-cache route and the emitted manifest alike. const DEFERRED_ASSET_GLOBS = DEFERRED_ASSET_DIRS.map((dir) => `${dir}/**`); /** Where the client reads the list of deferred assets to warm. */ @@ -380,7 +380,7 @@ export default defineConfig(async ({ mode }) => { // running tab fetches uncached to detect a newer deploy (see versionCheck.ts). // The deferred directories are excluded so a slow or dropped connection // can't abort the whole install; they are runtime-cached and warmed - // afterwards instead. See DEFERRED_ASSET_DIRS above. + // afterwards instead. See DEFERRED_ASSET_DIRS (scripts/deferredAssets.ts). // og-image.png is only ever fetched by link-preview crawlers — no reason // to ship it in every offline install. // vendor-posthog: only analytics users ever load it (plan 0030), so it is @@ -393,12 +393,11 @@ export default defineConfig(async ({ mode }) => { // DEFERRED_ASSET_DIRS). CacheFirst so that once warmed they are // served offline exactly as if precached — the difference is only // that failing to fetch one can no longer abort the install. - // The pattern is inlined rather than built from the constant - // because workbox-build serializes this function into the worker, - // where nothing from this module's scope exists. `sameOrigin` is - // supplied by Workbox's route matcher. - urlPattern: ({ url, sameOrigin }: { url: URL; sameOrigin: boolean }) => - sameOrigin && /^\/(?:samples|loggers)\//.test(url.pathname), + // workbox-build serializes this callback into the worker, where + // nothing from this module's scope exists, so the matcher bakes the + // pattern derived from DEFERRED_ASSET_DIRS into its source text. + // `sameOrigin` is supplied by Workbox's route matcher. + urlPattern: sameOriginPathMatcher(deferredAssetPathPattern(DEFERRED_ASSET_DIRS)), handler: "CacheFirst", options: { // No ExpirationPlugin here on purpose: the warm-up writes these From 7e56753454d70bb7eb52ee63cef44ca1937ac4c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:09:38 +0000 Subject: [PATCH 06/11] plan 0022: test the drag/course/waypoint load precedence Release review T4: the order a fresh load resolves in (confident course, then drag runs, then waypoint, then the nearest-track prompt) and the rule that a restored track/course shadows a saved drag tag lived only inline in useDataLoader. Both are extracted as pure functions (chooseAutoDetectPath, shouldRestoreDragSession) and covered, including that drag detection never runs when a course already matched. Extract only: the branch bodies are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- src/hooks/useDataLoader.test.ts | 42 +++++++++++++++++++++++-- src/hooks/useDataLoader.ts | 56 +++++++++++++++++++++++++++------ 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/hooks/useDataLoader.test.ts b/src/hooks/useDataLoader.test.ts index 356e17c0..3e4a44ac 100644 --- a/src/hooks/useDataLoader.test.ts +++ b/src/hooks/useDataLoader.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { detectionMetadataPatch, dragMetadataPatch } from "./useDataLoader"; +import { describe, expect, it, vi } from "vitest"; +import { chooseAutoDetectPath, detectionMetadataPatch, dragMetadataPatch, shouldRestoreDragSession } from "./useDataLoader"; const laps = [ { lapNumber: 1, lapTimeMs: 65000 }, @@ -65,3 +65,41 @@ describe("dragMetadataPatch (drag-session tagging)", () => { expect(patch.fastestLapNumber).toBeUndefined(); }); }); + +describe("chooseAutoDetectPath (load precedence, plan 0022)", () => { + const drag = { runs: [] }; + + it("a confident course wins and never runs drag detection", () => { + const detectDrag = vi.fn(() => drag); + expect(chooseAutoDetectPath({ isWaypointMode: false }, detectDrag)).toEqual({ kind: "course" }); + expect(detectDrag).not.toHaveBeenCalled(); + }); + + it("drag runs beat a waypoint result", () => { + expect(chooseAutoDetectPath({ isWaypointMode: true }, () => drag)).toEqual({ kind: "drag", drag }); + }); + + it("drag runs are found even when no course detected at all", () => { + expect(chooseAutoDetectPath(null, () => drag)).toEqual({ kind: "drag", drag }); + }); + + it("falls back to waypoint, then to the nearest-track prompt", () => { + expect(chooseAutoDetectPath({ isWaypointMode: true }, () => null)).toEqual({ kind: "waypoint" }); + expect(chooseAutoDetectPath(null, () => null)).toEqual({ kind: "nearest" }); + }); +}); + +describe("shouldRestoreDragSession (saved drag tag)", () => { + it("restores a recognised distance when no course was restored", () => { + expect(shouldRestoreDragSession(false, 1320)).toBe(true); + }); + + it("a restored track/course shadows a stale drag tag", () => { + expect(shouldRestoreDragSession(true, 1320)).toBe(false); + }); + + it("ignores a missing or unrecognised distance", () => { + expect(shouldRestoreDragSession(false, undefined)).toBe(false); + expect(shouldRestoreDragSession(false, 1234)).toBe(false); + }); +}); diff --git a/src/hooks/useDataLoader.ts b/src/hooks/useDataLoader.ts index c1410aa4..d32de45e 100644 --- a/src/hooks/useDataLoader.ts +++ b/src/hooks/useDataLoader.ts @@ -118,6 +118,45 @@ export function dragMetadataPatch( return patch; } +/** + * Whether a saved drag tag should restore this session as drag runs (plan + * 0022). A track/course restored from the same metadata shadows a stale drag + * tag, and only a recognised scoring distance counts. Pure so the load + * precedence stays testable. + */ +export function shouldRestoreDragSession( + restoredCourse: boolean, + storedDragDistanceFt: unknown, +): storedDragDistanceFt is DragDistanceFt { + return !restoredCourse && isDragDistanceFt(storedDragDistanceFt); +} + +/** Which branch a fresh (un-restored) load takes after auto-detection. */ +export type AutoDetectPath = + | { kind: "course" } + | { kind: "drag"; drag: D } + | { kind: "waypoint" } + | { kind: "nearest" }; + +/** + * Load precedence after auto-detection (plan 0022): a confident course wins + * outright (drag detection never even runs); otherwise drag-strip runs beat a + * waypoint result — a drag session's return road loops back near the staging + * lanes, so waypoint mode would mis-time out-and-back passes as laps; then + * waypoint; then the nearest-track prompt. `detectDrag` is a thunk so the scan + * only costs anything when a course didn't match. Pure so it stays testable. + */ +export function chooseAutoDetectPath( + detection: Pick | null, + detectDrag: () => D | null, +): AutoDetectPath { + if (detection && !detection.isWaypointMode) return { kind: "course" }; + const drag = detectDrag(); + if (drag) return { kind: "drag", drag }; + if (detection) return { kind: "waypoint" }; + return { kind: "nearest" }; +} + /** * File-load orchestration: connects sessionData (parsing), lapMgmt (lap calc), * sessionMeta (per-file kart/setup/weather metadata), and the track-prompt UI. @@ -203,7 +242,7 @@ export function useDataLoader({ // stored distance (a track/course restore above shadows a stale drag // tag; a corrupt file that no longer detects falls through to normal // detection). - if (!restoredFromMeta && isDragDistanceFt(meta.dragDistanceFt)) { + if (shouldRestoreDragSession(restoredFromMeta, meta.dragDistanceFt)) { const drag = detectDragRuns(parsedData.samples); if (drag) { applyDrag(parsedData.samples, drag, meta.dragDistanceFt, fileName, parsedData.startDate); @@ -245,8 +284,9 @@ export function useDataLoader({ tracksForRaceMode(tracks, parsedData.dovexMetadata?.raceMode), ); setDetectionResult(detection); + const path = chooseAutoDetectPath(detection, () => detectDragRuns(parsedData.samples)); - if (detection && !detection.isWaypointMode) { + if (path.kind === "course" && detection) { // Auto-detected a real course — apply directly, no prompt needed lapMgmt.setSelection({ trackName: detection.track.name, @@ -274,12 +314,10 @@ export function useDataLoader({ return; } - // No confident course match. Check for drag-strip data BEFORE accepting a - // waypoint result: a drag session's return road loops back near the - // staging lanes, so waypoint mode happily mis-times out-and-back passes - // as "laps". - const drag = detectDragRuns(parsedData.samples); - if (drag) { + // No confident course match: drag-strip data beats a waypoint result + // (see chooseAutoDetectPath). + if (path.kind === "drag") { + const { drag } = path; // Pre-apply at the suggested distance (mirrors the waypoint branch's // optimistic laps); nothing persists until the user confirms. applyDrag(parsedData.samples, drag, drag.suggestedDistanceFt, undefined, undefined, false); @@ -288,7 +326,7 @@ export function useDataLoader({ return; } - if (detection && detection.isWaypointMode) { + if (path.kind === "waypoint" && detection) { // Waypoint mode — apply laps and prompt the user to confirm lapMgmt.setLaps(detection.laps); lapMgmt.setSelectedLapNumber(pickFastestLapNumber(detection.laps)); From 33489d84174757c65ff5a26843f1a7d8bc209043 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:09:38 +0000 Subject: [PATCH 07/11] plan 0027: move the offline cache count into lib and test it Release review T5: useOfflineReadiness's only logic not already in the tested lib/offlineReadiness was the "how many deferred assets are cached" count. It moves there as countCachedAssets (injectable cache, a throwing lookup counts as a miss, no Cache API means zero) with tests; the hook keeps only browser plumbing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- src/hooks/useOfflineReadiness.ts | 17 ++--------------- src/lib/offlineReadiness.test.ts | 25 ++++++++++++++++++++++++- src/lib/offlineReadiness.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/hooks/useOfflineReadiness.ts b/src/hooks/useOfflineReadiness.ts index 962a3e79..9d49098d 100644 --- a/src/hooks/useOfflineReadiness.ts +++ b/src/hooks/useOfflineReadiness.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { computeReadiness, + countCachedAssets, readinessPercent, type OfflineReadiness, } from "@/lib/offlineReadiness"; @@ -18,20 +19,6 @@ export interface OfflineReadinessState { const supported = () => typeof navigator !== "undefined" && "serviceWorker" in navigator; -const countCached = async (urls: string[]): Promise => { - if (typeof caches === "undefined") return 0; - const hits = await Promise.all( - urls.map(async (url) => { - try { - return (await caches.match(url)) !== undefined; - } catch { - return false; - } - }), - ); - return hits.filter(Boolean).length; -}; - /** * Live "can this device work with no signal?" state, plus a way to finish the * job on demand. Backed by `lib/offlineReadiness` (pure) and `lib/offlineWarmup` @@ -47,7 +34,7 @@ export function useOfflineReadiness(): OfflineReadinessState & { const refresh = useCallback(async (urls: string[]) => { setControlled(supported() && navigator.serviceWorker.controller !== null); - setCached(await countCached(urls)); + setCached(await countCachedAssets(urls)); }, []); useEffect(() => { diff --git a/src/lib/offlineReadiness.test.ts b/src/lib/offlineReadiness.test.ts index 66effae3..3f01e98d 100644 --- a/src/lib/offlineReadiness.test.ts +++ b/src/lib/offlineReadiness.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { computeReadiness, readinessPercent } from "./offlineReadiness"; +import { computeReadiness, countCachedAssets, readinessPercent } from "./offlineReadiness"; const base = { serviceWorkerSupported: true, @@ -55,3 +55,26 @@ describe("readinessPercent", () => { expect(readinessPercent(-1, 4)).toBe(0); }); }); + +describe("countCachedAssets", () => { + const cache = (cached: string[], throwing: string[] = []) => ({ + match: async (req: RequestInfo | URL) => { + const url = String(req); + if (throwing.includes(url)) throw new Error("storage evicted"); + return cached.includes(url) ? new Response("") : undefined; + }, + }); + + it("counts the URLs already in the cache", async () => { + expect(await countCachedAssets(["/a", "/b", "/c"], cache(["/a", "/c"]))).toBe(2); + }); + + it("treats a lookup that throws as a miss instead of failing the readout", async () => { + expect(await countCachedAssets(["/a", "/b"], cache(["/a", "/b"], ["/b"]))).toBe(1); + }); + + it("is zero with no Cache API or nothing to check", async () => { + expect(await countCachedAssets(["/a"], undefined)).toBe(0); + expect(await countCachedAssets([], cache(["/a"]))).toBe(0); + }); +}); diff --git a/src/lib/offlineReadiness.ts b/src/lib/offlineReadiness.ts index e8463bfc..1d2b7dec 100644 --- a/src/lib/offlineReadiness.ts +++ b/src/lib/offlineReadiness.ts @@ -44,3 +44,29 @@ export function readinessPercent(cached: number, total: number): number { if (total <= 0) return 100; return Math.min(100, Math.max(0, Math.round((cached / total) * 100))); } + +/** The slice of the Cache Storage API the readiness count needs. */ +export type CacheMatcher = Pick; + +/** + * How many of `urls` are already cached. A lookup that throws (storage + * evicted mid-check, a quota error) counts as a miss rather than failing the + * whole readout, and no Cache API at all (old browser, insecure context) + * means nothing is cached. + */ +export async function countCachedAssets( + urls: string[], + cacheStorage: CacheMatcher | undefined = typeof caches === "undefined" ? undefined : caches, +): Promise { + if (!cacheStorage) return 0; + const hits = await Promise.all( + urls.map(async (url) => { + try { + return (await cacheStorage.match(url)) !== undefined; + } catch { + return false; + } + }), + ); + return hits.filter(Boolean).length; +} From d9779fc1a562bb62f588fe4a85409c572f5229dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:09:59 +0000 Subject: [PATCH 08/11] Drop the never-rendered upright Archivo face Release review PERF-3: the only font-brand use (the landing hero) is italic, so the upright variable face was ~35 KB in every offline install that no page ever drew. Only the italic face ships now; the index.css comment says how to bring the upright one back. Precache 4251.83 -> 4222.66 KiB (together with the PERF-2 split). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- src/assets/fonts/Archivo-Variable.woff2 | Bin 34928 -> 0 bytes src/index.css | 13 ++++--------- 2 files changed, 4 insertions(+), 9 deletions(-) delete mode 100644 src/assets/fonts/Archivo-Variable.woff2 diff --git a/src/assets/fonts/Archivo-Variable.woff2 b/src/assets/fonts/Archivo-Variable.woff2 deleted file mode 100644 index 716d4abd199a236dc4b9e112f63429b9b47ce911..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34928 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 E05fT^n*aa+ diff --git a/src/index.css b/src/index.css index 3072f4ed..d28dd20f 100644 --- a/src/index.css +++ b/src/index.css @@ -14,15 +14,10 @@ /* Archivo is the PerchWerks/LapWing brand typeface (perchwerks-style/fonts). It's used only for display type (`font-brand`) — the dense app UI stays on Inter so tables and toolbars keep their metrics. Bundled from src/ so Vite - hashes it into the SW precache like the @fontsource files. */ -@font-face { - font-family: "Archivo"; - src: url("./assets/fonts/Archivo-Variable.woff2") format("woff2"); - font-weight: 100 900; - font-style: normal; - font-display: swap; -} - + hashes it into the SW precache like the @fontsource files. + Only the italic face ships: every `font-brand` use is italic, and the upright + variable face was ~35 KB of precache no page ever rendered. Add it back + (Archivo-Variable.woff2, font-style: normal) if upright brand type appears. */ @font-face { font-family: "Archivo"; src: url("./assets/fonts/Archivo-Variable-Italic.woff2") format("woff2"); From 9c6074f5f40d94a87ce19edb965b9e227b94ed09 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:09:59 +0000 Subject: [PATCH 09/11] plans 0024/0025/0029: keep native-only and GoPro code off the main chunk Release review PERF-2 (+ REL-5). The eager graph statically imported the GoPro MP4/GPMF extractor (via FileImport and datalogParser), the Insta360 camera player and the native video-store bridge (via useVideoSync), so every web visitor downloaded code only a GoPro import or the LapWing shell ever runs. - goproDetect.ts holds the cheap name/ftyp gates the routers need on every import; goproImport (and mp4Boxes/gpmf behind it) is now dynamic-imported when a video actually arrives. - useVideoSync loads nativeVideoStore and NativePlayerElement on first use (type-only imports otherwise); the store's change-event constant moves to nativeVideoStoreEvents.ts so the hook can listen without pulling in the bridge. - VideoPlayer (lazy GraphViewTab chunk) loads the Insta360 IPC and the native export bridge only inside the native app. Main chunk: 754.68 -> 738.13 kB raw, 231.04 -> 225.82 kB gzip. None of the insta360_*/video_store_*/GPMF code remains in it. Also drops the two leftover console.log calls in VideoPlayer's save paths (Rule 7). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- src/components/FileImport.tsx | 4 +++- src/components/VideoPlayer.tsx | 17 ++++++++++------- src/hooks/useVideoSync.ts | 14 +++++++++++--- src/lib/datalogParser.ts | 4 +++- src/lib/gopro/goproDetect.ts | 27 +++++++++++++++++++++++++++ src/lib/gopro/goproImport.ts | 16 ++-------------- src/lib/gopro/mp4Boxes.ts | 7 +------ src/lib/nativeVideoStore.ts | 11 ++--------- src/lib/nativeVideoStoreEvents.ts | 13 +++++++++++++ 9 files changed, 72 insertions(+), 41 deletions(-) create mode 100644 src/lib/gopro/goproDetect.ts create mode 100644 src/lib/nativeVideoStoreEvents.ts diff --git a/src/components/FileImport.tsx b/src/components/FileImport.tsx index 4f60fdd9..40bff814 100644 --- a/src/components/FileImport.tsx +++ b/src/components/FileImport.tsx @@ -2,7 +2,7 @@ import { lazy, Suspense, useCallback, useRef, useState } from "react"; import { useTranslation, Trans } from "react-i18next"; import { Upload, Loader2, LifeBuoy } from "lucide-react"; import { parseDatalogContent, parseDatalogFile } from "@/lib/datalogParser"; -import { importGoProVideo, isGoProVideoFile } from "@/lib/gopro/goproImport"; +import { isGoProVideoFile } from "@/lib/gopro/goproDetect"; import { stageGoProVideo, type StagedVideoFile } from "@/lib/gopro/videoHandoff"; import { groupVideoRecordings } from "@/lib/videoPlaylist"; import { ParsedData } from "@/types/racing"; @@ -95,6 +95,8 @@ export function FileImport({ onDataLoaded, autoSave, autoSaveFile }: FileImportP // One session per recording: a mixed selection is ambiguous, so say so // rather than silently picking one. if (!recording || groups.length > 1) throw new Error(t("fileImport.goproOneRecording")); + // The extractor is loaded on demand so it stays off the landing payload. + const { importGoProVideo } = await import("@/lib/gopro/goproImport"); const result = await importGoProVideo( recording.files.map((f) => f.file), (p) => setProgress(t("fileImport.goproProgress", { ...p })), diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 42f6fc9d..bf683a97 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -19,9 +19,7 @@ import { OverlaySettingsPanel } from "@/components/video-overlays/OverlaySetting import { VideoExportDialog, ExportOptions } from "@/components/video-overlays/VideoExportDialog"; import { OverlayCanvas } from "@/components/video-overlays/OverlayCanvas"; import { startVideoExport, downloadBlob, ExportContext, ExportSource } from "@/lib/videoExport"; -import { startNativeVideoExport } from "@/lib/nativeVideoExport"; import { isNativeApp } from "@/lib/platform"; -import { insta360SdkInfo } from "@/lib/insta360/ipc"; import { NativePlayerElement } from "@/lib/insta360/nativePlayer"; import { DEFAULT_VIEW_POSE } from "@/lib/insta360/types"; import { Insta360ViewLayer } from "@/components/insta360/Insta360ViewLayer"; @@ -309,7 +307,9 @@ export const VideoPlayer = memo(function VideoPlayer({ useEffect(() => { if (!isNativeApp()) return; let alive = true; - void insta360SdkInfo().then((info) => { if (alive) setInsta360Available(info.available); }); + void import("@/lib/insta360/ipc") + .then(({ insta360SdkInfo }) => insta360SdkInfo()) + .then((info) => { if (alive) setInsta360Available(info.available); }); return () => { alive = false; }; }, []); const nativePlayer = state.nativeSource ? actions.videoRef.current : null; @@ -612,7 +612,6 @@ export const VideoPlayer = memo(function VideoPlayer({ const exportType = options.range === "lap" ? "lap" as const : "session" as const; const lapNum = options.range === "lap" && selectedLapNumber != null ? selectedLapNumber : undefined; saveSessionVideo(sessionFileName, blob, vidName, exportType, options.includeOverlays, lapNum).then(() => { - console.log("Video saved to app storage"); actions.refreshStoredMeta(); }).catch(err => { console.error("Failed to save video:", err); @@ -635,15 +634,19 @@ export const VideoPlayer = memo(function VideoPlayer({ onSavedToDevice: (uri) => { setIsExporting(false); setShowExportDialog(false); - console.log("Video saved to gallery:", uri); toast({ title: t("export.savedToGallery") }); }, } satisfies Parameters[3]; // In the native shell, the hardware pipeline does the transcode (plan // 0024); it resolves null when this shell can't (web, desktop stub, old - // app), and then the in-WebView exporter takes over unchanged. - void startNativeVideoExport(exportSource, exportContext, exportOptions, exportCallbacks).then( + // app), and then the in-WebView exporter takes over unchanged. The bridge + // is native-only, so the web build never downloads it. + const nativeExport = isNativeApp() + ? import("@/lib/nativeVideoExport").then(({ startNativeVideoExport }) => + startNativeVideoExport(exportSource, exportContext, exportOptions, exportCallbacks)) + : Promise.resolve(null); + void nativeExport.then( (native) => { if (!native) startVideoExport(exportSource, exportContext, exportOptions, exportCallbacks); }, diff --git a/src/hooks/useVideoSync.ts b/src/hooks/useVideoSync.ts index 8ebd7b2f..2750a61f 100644 --- a/src/hooks/useVideoSync.ts +++ b/src/hooks/useVideoSync.ts @@ -2,12 +2,11 @@ import { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { GpsSample } from "@/types/racing"; import { saveVideoSync, loadVideoSync, VideoSyncRecord, VideoSyncChunk } from "@/lib/videoStorage"; import { loadSessionVideo, hasSessionVideo, deleteSessionVideo, getSessionVideoMeta, type StoredVideoMeta } from "@/lib/videoFileStorage"; -import { NativePlayerElement, type VideoSurface } from "@/lib/insta360/nativePlayer"; +import type { NativePlayerElement, VideoSurface } from "@/lib/insta360/nativePlayer"; import type { Insta360CameraFile } from "@/lib/insta360/types"; import { - getNativeStoredVideo, storeNativeVideo, NATIVE_VIDEO_STORE_CHANGED, type NativeVideoStoreChangedDetail, -} from "@/lib/nativeVideoStore"; +} from "@/lib/nativeVideoStoreEvents"; import { isNativeApp } from "@/lib/platform"; import type { OverlaySettings } from "@/components/video-overlays/types"; import { DEFAULT_OVERLAY_SETTINGS } from "@/components/video-overlays/types"; @@ -16,6 +15,14 @@ import { coverageOf, sessionMsToVideoSec, videoSecToSessionMs, fitVideoTimeline, import { buildPlaylist, groupVideoRecordings, virtualToLocal, localToVirtual, type Playlist, type VideoRecording } from "@/lib/videoPlaylist"; import { takeStagedGoProVideo, type StagedGoProVideo } from "@/lib/gopro/videoHandoff"; +// Native-shell-only modules (plans 0024/0025), loaded on first use so the web +// build's main chunk never carries the store bridge or the camera player. Every +// caller is already behind `isNativeApp()` or a native-only action. +const getNativeStoredVideo = async (fileName: string) => + (await import("@/lib/nativeVideoStore")).getNativeStoredVideo(fileName); +const storeNativeVideo = async (fileName: string, file: File) => + (await import("@/lib/nativeVideoStore")).storeNativeVideo(fileName, file); + interface UseVideoSyncOptions { samples: GpsSample[]; allSamples: GpsSample[]; @@ -507,6 +514,7 @@ export function useVideoSync({ samples, allSamples, currentIndex, onScrub, sessi // aren't copied into the store, and can't be exported (v1). const loadCameraRecording = useCallback(async (file: Insta360CameraFile, size: { width: number; height: number }) => { revokeAllUrls(); + const { NativePlayerElement } = await import("@/lib/insta360/nativePlayer"); const player = new NativePlayerElement(file, { width: size.width, height: size.height }); nativePlayerRef.current = player; setNativeSource({ kind: "insta360", file, is360: file.is360 }); diff --git a/src/lib/datalogParser.ts b/src/lib/datalogParser.ts index 3433bdc3..2a11b175 100644 --- a/src/lib/datalogParser.ts +++ b/src/lib/datalogParser.ts @@ -11,7 +11,7 @@ import { parseAimFile, isAimFormat, hasAimSignature } from './aimParser'; import { isMotecLdFormat, parseMotecLdFile, isMotecCsvFormat, parseMotecCsvFile } from './motecParser'; import { isIracingFormat, parseIracingFile } from './iracingParser'; import { isXrkFile, parseXrkFile, type XrkProgressCallback } from './xrk/xrkImporter'; -import { isGoProVideoBuffer, isGoProVideoFile, parseGoProVideoFile } from './gopro/goproImport'; +import { isGoProVideoBuffer, isGoProVideoFile } from './gopro/goproDetect'; import { beginFileLoading, updateFileLoading, endFileLoading } from './fileLoadingState'; /** @@ -73,6 +73,8 @@ async function routeDatalogFile( // this MUST come before the whole-file `arrayBuffer()` below (a camera file // is gigabytes; its telemetry is a few hundred KB). if (isGoProVideoFile(file.name)) { + // The MP4/GPMF extractor loads only when a video actually arrives. + const { parseGoProVideoFile } = await import('./gopro/goproImport'); return parseGoProVideoFile(file, (p) => onProgress?.({ phase: 'parse', message: `Reading GoPro telemetry… ${p.done}/${p.total}`, diff --git a/src/lib/gopro/goproDetect.ts b/src/lib/gopro/goproDetect.ts new file mode 100644 index 00000000..7570683a --- /dev/null +++ b/src/lib/gopro/goproDetect.ts @@ -0,0 +1,27 @@ +/** + * Cheap GoPro-video gates (plan 0029). Split from the importer because the + * eager file-routing code (`datalogParser`, `FileImport`) needs to *recognise* + * a video on every import, while the MP4/GPMF extractor itself only loads + * when one actually arrives — keeping it off the main chunk. + */ + +/** Containers a GoPro writes; `.360` is the MAX/Fusion spherical variant. */ +const GOPRO_VIDEO_EXTENSIONS = [".mp4", ".mov", ".360"]; + +/** Name-based gate used before any bytes are read. */ +export function isGoProVideoFile(name: string): boolean { + const lower = name.toLowerCase(); + return GOPRO_VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} + +/** True when the buffer opens with an ISO-BMFF `ftyp` box (MP4/MOV/.360). */ +export function isIsoBmff(buffer: ArrayBuffer): boolean { + if (buffer.byteLength < 12) return false; + const view = new DataView(buffer); + return String.fromCharCode(view.getUint8(4), view.getUint8(5), view.getUint8(6), view.getUint8(7)) === "ftyp"; +} + +/** True for an in-memory MP4/MOV — the sync content router refuses these. */ +export function isGoProVideoBuffer(buffer: ArrayBuffer): boolean { + return isIsoBmff(buffer); +} diff --git a/src/lib/gopro/goproImport.ts b/src/lib/gopro/goproImport.ts index 9d0bae1e..7e669cda 100644 --- a/src/lib/gopro/goproImport.ts +++ b/src/lib/gopro/goproImport.ts @@ -7,22 +7,10 @@ import type { ParsedData } from "@/types/racing"; import { parseDoveFile } from "@/lib/doveParser"; import { orderVideoFiles } from "@/lib/videoPlaylist"; import { beginFileLoading, updateFileLoading, endFileLoading } from "@/lib/fileLoadingState"; -import { blobByteSource, isIsoBmff, readGpmdTrack, readSamples } from "./mp4Boxes"; +import { blobByteSource, readGpmdTrack, readSamples } from "./mp4Boxes"; import { buildGoProSession, rowsFromPayloads, type GoProGpsRow, type GoProSession } from "./goproTelemetry"; -/** Containers a GoPro writes; `.360` is the MAX/Fusion spherical variant. */ -const GOPRO_VIDEO_EXTENSIONS = [".mp4", ".mov", ".360"]; - -/** Cheap name-based gate used before any bytes are read. */ -export function isGoProVideoFile(name: string): boolean { - const lower = name.toLowerCase(); - return GOPRO_VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext)); -} - -/** True for an in-memory MP4/MOV — the sync content router refuses these. */ -export function isGoProVideoBuffer(buffer: ArrayBuffer): boolean { - return isIsoBmff(buffer); -} +export { isGoProVideoBuffer, isGoProVideoFile } from "./goproDetect"; /** The session file name a video import is saved under: `GH010042.MP4` → `GH010042.dove`. */ export function goProSessionFileName(videoName: string): string { diff --git a/src/lib/gopro/mp4Boxes.ts b/src/lib/gopro/mp4Boxes.ts index 8694b66e..4711bf0b 100644 --- a/src/lib/gopro/mp4Boxes.ts +++ b/src/lib/gopro/mp4Boxes.ts @@ -110,12 +110,7 @@ function findChild(view: DataView, start: number, end: number, type: string): Bo return null; } -/** True when the buffer opens with an ISO-BMFF `ftyp` box (MP4/MOV/.360). */ -export function isIsoBmff(buffer: ArrayBuffer): boolean { - if (buffer.byteLength < 12) return false; - const view = new DataView(buffer); - return fourcc(view, 4) === "ftyp"; -} +export { isIsoBmff } from "./goproDetect"; /** * Walk the top-level boxes with ranged reads and return the `moov` payload. diff --git a/src/lib/nativeVideoStore.ts b/src/lib/nativeVideoStore.ts index 83332e8b..fb770fb9 100644 --- a/src/lib/nativeVideoStore.ts +++ b/src/lib/nativeVideoStore.ts @@ -26,6 +26,7 @@ import { api } from "@/lib/loggers/native/ipc"; import { blobToBase64, NATIVE_CHUNK_BYTES } from "@/lib/nativeBytes"; import { isNativeApp } from "@/lib/platform"; import { isNativeFeatureUnavailable } from "@/lib/nativeUnavailable"; +import { NATIVE_VIDEO_STORE_CHANGED, type NativeVideoStoreChangedDetail } from "@/lib/nativeVideoStoreEvents"; export interface NativeStoredVideo { key: string; @@ -53,15 +54,7 @@ export interface NativeStoredVideoEntry extends StoredVideoInfo { storedAtMs?: number; } -/** - * Fired on `window` after a stored video is removed or the store is cleared, - * so a session that is playing (or exporting) from the deleted copy can react. - * `removedKeys` is `null` when everything went. - */ -export const NATIVE_VIDEO_STORE_CHANGED = "native-video-store-changed"; -export interface NativeVideoStoreChangedDetail { - removedKeys: string[] | null; -} +export { NATIVE_VIDEO_STORE_CHANGED, type NativeVideoStoreChangedDetail } from "@/lib/nativeVideoStoreEvents"; function announceStoreChange(removedKeys: string[] | null): void { if (typeof window === "undefined" || typeof CustomEvent === "undefined") return; diff --git a/src/lib/nativeVideoStoreEvents.ts b/src/lib/nativeVideoStoreEvents.ts new file mode 100644 index 00000000..29a1795f --- /dev/null +++ b/src/lib/nativeVideoStoreEvents.ts @@ -0,0 +1,13 @@ +/** + * The native video store's change event (plan 0024), split from + * `nativeVideoStore.ts` so eager listeners (`useVideoSync`) can subscribe + * without pulling the store's IPC bridge into the main chunk. + * + * Fired on `window` after a stored video is removed or the store is cleared, + * so a session that is playing (or exporting) from the deleted copy can react. + * `removedKeys` is `null` when everything went. + */ +export const NATIVE_VIDEO_STORE_CHANGED = "native-video-store-changed"; +export interface NativeVideoStoreChangedDetail { + removedKeys: string[] | null; +} From 87c20d06d9d1cd1a41f4297948dced3fbce61c2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 01:10:09 +0000 Subject: [PATCH 10/11] 4.2.0 review: CHANGELOG structure and docs drift (low findings) - REL-3/DOC-2: fold the stray second "### Added" block (drag mode) into the first one and cite plan 0022. - REL-4: the read-only banner fix moves from Changed to Fixed. - Fixed entries for this pass's user-visible fixes (setup history errors, malformed/hostile GoPro files, Insta360 stream replacement). - DOC-5: plan 0022 is done, not IN PROGRESS. - DOC-6: CLAUDE.md's video-overlays entry described the per-widget *Overlay components plan 0023 deleted; it now names the single scene renderer. - DOC-7: README marks .360 GoPro import experimental, matching plan 0029's "untested". - CLAUDE.md / plan 0029 updated for goproDetect, the dynamic GoPro import, the deferredAssets module and the italic-only Archivo. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NZ3Pt89yubdPtYn5Wc8fjW --- CHANGELOG.md | 33 ++++++++++++++--------- CLAUDE.md | 9 ++++--- README.md | 6 +++-- docs/plans/0022-drag-mode.md | 5 ++-- docs/plans/0029-gopro-gps-video-import.md | 10 +++++-- 5 files changed, 41 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1fec4b4..22564b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unchanged, and an older shell (or a multi-file recording) falls back to the previous exporter automatically. Requires a LapWing build with the `video_export_*` pipeline (plan 0024). +- **Drag mode** — drop in a log from a drag strip (or any venue the app doesn't + know) and standing-start runs are detected automatically: pick 1/8 mile, + 1000 ft or 1/4 mile and every pass gets a time-slip style breakdown — ET at + the scoring distance plus 60 ft / 330 ft / 660 ft / 1000 ft splits. Passes + that lifted early stay listed as incomplete with the splits they did reach, + and your chosen distance is remembered per file and switchable from the + session header. (Plan 0022.) ### Changed - **New LapWing look.** The app now wears the LapWing brand from the @@ -116,9 +123,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The footer now links to PerchWerks.** "LapWing is a PerchWerks product", with the shared bird mark, takes you to perchwerks.com (it replaces the old "Operated by PerchWerks LLC" line). -- **The shared-session "read-only" banner text is readable again** — it was - drawn in a colour meant for text on a solid amber fill, so it nearly - vanished against the banner's pale tint. - **The app no longer loses its whole offline cache to a flaky connection.** Caching the app was previously one ~10 MB all-or-nothing download: if anything failed partway through — losing signal at a track, say — *nothing* was cached @@ -149,16 +153,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 burn-in export will call this same renderer, so all three outputs match by construction. -### Added -- **Drag mode** — drop in a log from a drag strip (or any venue the app doesn't - know) and standing-start runs are detected automatically: pick 1/8 mile, - 1000 ft or 1/4 mile and every pass gets a time-slip style breakdown — ET at - the scoring distance plus 60 ft / 330 ft / 660 ft / 1000 ft splits. Passes - that lifted early stay listed as incomplete with the splits they did reach, - and your chosen distance is remembered per file and switchable from the - session header. - ### Fixed +- **The shared-session "read-only" banner text is readable again** — it was + drawn in a colour meant for text on a solid amber fill, so it nearly + vanished against the banner's pale tint. +- **Setup history no longer hangs on "Loading…" or fails silently.** If the + history can't be read, the panel now says so with a Retry button, and a + rollback or duplicate that fails shows an error instead of doing nothing. + (Plan 0028.) +- **One damaged telemetry packet no longer rejects a whole GoPro import.** A + malformed GPS payload is skipped (a one-second gap) and the rest of the + recording imports. A corrupt or hostile MP4 whose sample table claims + billions of entries is also capped instead of freezing the tab. (Plan 0029.) +- **Insta360 (Android app): switching recordings quickly could stop the new + stream.** A previous recording's player closing late no longer shuts down + the one you just opened. (Plan 0025.) - **Save to Gallery / export in the Android app did nothing.** Every export (and the app's background copy of your video) failed on its very first chunk: the way the app handed video bytes to the shell doesn't exist on diff --git a/CLAUDE.md b/CLAUDE.md index 704532c2..80e3f6b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ src/ │ ├── graphview/ # Pro mode: GraphPanel, GraphViewPanel, MiniMap, SingleSeriesChart, GGDiagram, InfoBox, PanelCard (resizable card chrome for relocated Video/Mini-Map panels). The left column collapses via a divider flag tab (any screen size), and Video/Mini-Map can be relocated into the resizable graph stack from the top of the "Add Graph" picker (GraphPanel reports which are active so the host drops its duplicate VideoPlayer — single shared video ref). Split graphs (tablet+): SecondaryGraphStack mirrors the main panel's graph set for a chosen overlay lap in a draggable two-up view, overriding PlaybackContext for its subtree (nested PlaybackProvider) so one cursor lands on the same track position in both laps (distance-mapped via lib/referenceUtils mapIndexByDistance); SecondaryVideo is a literal second, lap-synced