From b5c9bce1aa46f33ac1fa1ec97f95a38a68b8a7fe Mon Sep 17 00:00:00 2001 From: phenom Date: Sat, 15 Aug 2026 21:11:43 -0400 Subject: [PATCH] Include content delivery runtime --- .gitignore | 2 +- scripts/content/prepare-android-bootstrap.mjs | 51 ++++ scripts/content/publish-content.mjs | 250 +++++++++++++++++ scripts/content/publish-content.test.mjs | 48 ++++ src/content/contentManager.ts | 260 ++++++++++++++++++ src/content/contentManifest.test.ts | 40 +++ src/content/contentManifest.ts | 107 +++++++ src/content/nativeContentStorage.ts | 39 +++ src/content/useContentManager.ts | 13 + 9 files changed, 809 insertions(+), 1 deletion(-) create mode 100644 scripts/content/prepare-android-bootstrap.mjs create mode 100644 scripts/content/publish-content.mjs create mode 100644 scripts/content/publish-content.test.mjs create mode 100644 src/content/contentManager.ts create mode 100644 src/content/contentManifest.test.ts create mode 100644 src/content/contentManifest.ts create mode 100644 src/content/nativeContentStorage.ts create mode 100644 src/content/useContentManager.ts diff --git a/.gitignore b/.gitignore index fe2a84e7..a431d3f5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,6 @@ runewaker-rig-probe-*/ data/runtime/ backups/ releases/ -content/ +/content/ dist-android/ .android-public/ diff --git a/scripts/content/prepare-android-bootstrap.mjs b/scripts/content/prepare-android-bootstrap.mjs new file mode 100644 index 00000000..1a54e0f2 --- /dev/null +++ b/scripts/content/prepare-android-bootstrap.mjs @@ -0,0 +1,51 @@ +import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + PROJECT_ROOT, + createContentManifest, + scanContentSources, +} from "./publish-content.mjs"; + +const outputDirectory = path.join(PROJECT_ROOT, ".android-public"); +if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".android-public") { + throw new Error(`Refusing to replace unexpected Android staging directory: ${outputDirectory}`); +} + +const sources = [ + { directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" }, + { directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" }, + { + directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"), + logicalPrefix: "assets/creatures/wailing-caverns", + }, + { + directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"), + logicalPrefix: "assets/game/dungeons/wailing-caverns", + }, +]; + +await rm(outputDirectory, { recursive: true, force: true }); +await mkdir(outputDirectory, { recursive: true }); + +const files = await scanContentSources(sources); +for (const file of files) { + const destination = path.join(outputDirectory, ...file.logicalPath.split("/")); + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(file.sourcePath, destination); +} + +const generated = createContentManifest(files, { baseUrl: "/" }); +const bootstrapManifest = { + ...generated, + files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [ + logicalPath, + { ...entry, url: logicalPath }, + ])), +}; +const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json"); +await mkdir(path.dirname(manifestPath), { recursive: true }); +await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8"); + +const bytes = files.reduce((total, file) => total + file.bytes, 0); +console.log(`Prepared Android bootstrap content: ${files.length} files (${bytes} bytes)`); +console.log(`Bootstrap version: ${bootstrapManifest.version}`); diff --git a/scripts/content/publish-content.mjs b/scripts/content/publish-content.mjs new file mode 100644 index 00000000..7ff2470e --- /dev/null +++ b/scripts/content/publish-content.mjs @@ -0,0 +1,250 @@ +import { createHash } from "node:crypto"; +import { + constants, + copyFile, + mkdir, + readdir, + rename, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); +export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../.."); +export const CONTENT_SCHEMA_VERSION = 1; +export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0"; +export const BUNDLED_ANDROID_PACKS = Object.freeze([ + "core:ui", + "core:equipment", + "dungeon:wailing-caverns", +]); + +const RUNTIME_EXTENSIONS = new Set([ + ".avif", ".glb", ".json", ".ktx2", ".mp3", ".ogg", ".png", + ".svg", ".wav", ".webm", ".webp", +]); + +export const DEFAULT_CONTENT_SOURCES = Object.freeze([ + { + directory: path.join(PROJECT_ROOT, "public", "assets"), + logicalPrefix: "assets", + }, + { + directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons"), + logicalPrefix: "assets/game/dungeons", + }, +]); + +function normalizedRelativePath(value) { + return value.split(path.sep).join("/").replace(/^\/+/, ""); +} + +function encodeUrlPath(value) { + return value.split("/").map(encodeURIComponent).join("/"); +} + +function packLabel(packId) { + const [category, id = category] = packId.split(":", 2); + const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" "); + if (category === "dungeon") return name; + if (category === "characters") return `${name} Characters`; + if (category === "manastorm") return `${name} Manastorm`; + if (packId === "shared:creatures") return "Shared Creatures"; + if (packId === "core:ui") return "Interface"; + if (packId === "core:equipment") return "Starter Equipment"; + return name; +} + +export function contentPackForPath(logicalPath) { + const segments = normalizedRelativePath(logicalPath).split("/"); + if (segments[0] !== "assets") return "core:misc"; + if (segments[1] === "characters" && segments[2]) return `characters:${segments[2]}`; + if (segments[1] === "creatures" && segments[2]) { + return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`; + } + if (segments[1] === "game" && segments[2] === "creatures" && segments[3]) { + return segments[3] === "shared" ? "shared:creatures" : `dungeon:${segments[3]}`; + } + if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) { + return `dungeon:${segments[3]}`; + } + if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) { + return `manastorm:${segments[3]}`; + } + if (segments[1] === "ui") return "core:ui"; + if (segments[1] === "equipment") return "core:equipment"; + return "core:misc"; +} + +async function walkFiles(directory) { + const files = []; + async function visit(current) { + const entries = await readdir(current, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) await visit(fullPath); + else if (entry.isFile() && RUNTIME_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(fullPath); + } + } + await visit(directory); + return files; +} + +async function sha256File(filePath) { + const { createReadStream } = await import("node:fs"); + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) { + const discovered = new Map(); + for (const source of sources) { + const sourceDirectory = path.resolve(source.directory); + const files = await walkFiles(sourceDirectory); + for (const sourcePath of files) { + const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath)); + const logicalPath = normalizedRelativePath(`${source.logicalPrefix}/${relative}`); + const fileStats = await stat(sourcePath); + const sha256 = await sha256File(sourcePath); + const prior = discovered.get(logicalPath); + if (prior) { + if (prior.sha256 !== sha256) { + throw new Error(`Two content sources map different files to ${logicalPath}`); + } + continue; + } + discovered.set(logicalPath, { + logicalPath, + sourcePath, + sha256, + bytes: fileStats.size, + extension: path.extname(sourcePath).toLowerCase(), + packId: contentPackForPath(logicalPath), + }); + } + } + return [...discovered.values()].sort((left, right) => left.logicalPath.localeCompare(right.logicalPath)); +} + +function contentVersion(files) { + const digest = createHash("sha256"); + for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\n`); + return `content-${digest.digest("hex").slice(0, 16)}`; +} + +export function createContentManifest(files, options = {}) { + const baseUrl = String(options.baseUrl ?? "/content/objects").replace(/\/+$/, ""); + const version = options.version ?? contentVersion(files); + const packs = new Map(); + const fileEntries = {}; + + for (const file of files) { + const objectName = `${file.sha256}${file.extension}`; + const logicalUrl = `/${normalizedRelativePath(file.logicalPath)}`; + fileEntries[logicalUrl] = { + packId: file.packId, + url: `${baseUrl}/${encodeUrlPath(objectName)}`, + sha256: file.sha256, + bytes: file.bytes, + }; + const pack = packs.get(file.packId) ?? { + id: file.packId, + label: packLabel(file.packId), + bytes: 0, + fileCount: 0, + bundledOnAndroid: BUNDLED_ANDROID_PACKS.includes(file.packId), + dependencies: [], + }; + pack.bytes += file.bytes; + pack.fileCount += 1; + packs.set(file.packId, pack); + } + + if (packs.has("shared:creatures")) { + for (const pack of packs.values()) { + if (pack.id.startsWith("dungeon:") || pack.id.startsWith("manastorm:")) { + pack.dependencies = ["shared:creatures"]; + } + } + } + + return { + schemaVersion: CONTENT_SCHEMA_VERSION, + version, + createdAt: options.createdAt ?? new Date().toISOString(), + minimumAppVersion: options.minimumAppVersion ?? DEFAULT_MINIMUM_APP_VERSION, + packs: [...packs.values()].sort((left, right) => left.id.localeCompare(right.id)), + files: fileEntries, + }; +} + +async function copyObjectIfMissing(file, objectDirectory) { + const objectName = `${file.sha256}${file.extension}`; + const destination = path.join(objectDirectory, objectName); + try { + const destinationStats = await stat(destination); + if (destinationStats.size !== file.bytes) throw new Error(`Published object has the wrong size: ${destination}`); + return false; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + try { + await copyFile(file.sourcePath, destination, constants.COPYFILE_EXCL); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + return true; +} + +async function replaceJsonAtomically(destination, value) { + const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await rename(temporary, destination); +} + +export async function publishContent(options = {}) { + const contentDirectory = path.resolve(options.contentDirectory ?? process.env.CONTENT_DIR ?? path.join(PROJECT_ROOT, "content")); + const objectDirectory = path.join(contentDirectory, "objects"); + const manifestDirectory = path.join(contentDirectory, "manifests"); + await mkdir(objectDirectory, { recursive: true }); + await mkdir(manifestDirectory, { recursive: true }); + + const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES); + let copiedObjects = 0; + for (const file of files) { + if (await copyObjectIfMissing(file, objectDirectory)) copiedObjects += 1; + } + const manifest = createContentManifest(files, options); + await replaceJsonAtomically(path.join(manifestDirectory, `${manifest.version}.json`), manifest); + await replaceJsonAtomically(path.join(contentDirectory, "manifest.json"), manifest); + return { contentDirectory, copiedObjects, manifest }; +} + +function parseCliArguments(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === "--content-dir") options.contentDirectory = argv[++index]; + else if (value === "--base-url") options.baseUrl = argv[++index]; + else if (value === "--minimum-app-version") options.minimumAppVersion = argv[++index]; + else throw new Error(`Unknown content publishing argument: ${value}`); + } + return options; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const result = await publishContent(parseCliArguments(process.argv.slice(2))); + const bytes = result.manifest.packs.reduce((total, pack) => total + pack.bytes, 0); + console.log(`Published ${result.manifest.files ? Object.keys(result.manifest.files).length : 0} files (${bytes} bytes)`); + console.log(`Content version: ${result.manifest.version}`); + console.log(`New immutable objects: ${result.copiedObjects}`); + console.log(`Manifest: ${path.join(result.contentDirectory, "manifest.json")}`); +} diff --git a/scripts/content/publish-content.test.mjs b/scripts/content/publish-content.test.mjs new file mode 100644 index 00000000..1df44ecb --- /dev/null +++ b/scripts/content/publish-content.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + contentPackForPath, + createContentManifest, + publishContent, + scanContentSources, +} from "./publish-content.mjs"; + +test("assigns stable player-facing packs from logical asset paths", () => { + assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns"); + assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns"); + assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "characters:wow335a-v1"); + assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1"); +}); + +test("publishes objects before a deterministic latest manifest", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "healer-man-content-test-")); + const sourceDirectory = path.join(directory, "source"); + const contentDirectory = path.join(directory, "content"); + await mkdir(path.join(sourceDirectory, "wailing-caverns"), { recursive: true }); + await writeFile(path.join(sourceDirectory, "wailing-caverns", "visual.glb"), Buffer.from("glb-one")); + const sources = [{ directory: sourceDirectory, logicalPrefix: "assets/game/dungeons" }]; + + try { + const scanned = await scanContentSources(sources); + const firstManifest = createContentManifest(scanned, { createdAt: "2026-08-14T00:00:00.000Z" }); + assert.equal(Object.keys(firstManifest.files).length, 1); + assert.equal(firstManifest.packs[0].id, "dungeon:wailing-caverns"); + + const first = await publishContent({ contentDirectory, sources, createdAt: "2026-08-14T00:00:00.000Z" }); + assert.equal(first.copiedObjects, 1); + const latest = JSON.parse(await readFile(path.join(contentDirectory, "manifest.json"), "utf8")); + assert.equal(latest.version, first.manifest.version); + const entry = latest.files["/assets/game/dungeons/wailing-caverns/visual.glb"]; + assert.match(entry.url, /^\/content\/objects\/[a-f0-9]{64}\.glb$/); + assert.equal(await readFile(path.join(contentDirectory, "objects", path.basename(entry.url)), "utf8"), "glb-one"); + + const second = await publishContent({ contentDirectory, sources, createdAt: "2026-08-14T00:00:00.000Z" }); + assert.equal(second.copiedObjects, 0); + assert.equal(second.manifest.version, first.manifest.version); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/src/content/contentManager.ts b/src/content/contentManager.ts new file mode 100644 index 00000000..08c30939 --- /dev/null +++ b/src/content/contentManager.ts @@ -0,0 +1,260 @@ +import { Capacitor } from "@capacitor/core"; +import { + appVersionMeetsMinimum, + changedFilesForPack, + filesForContentPack, + parseContentManifest, + type ContentManifest, + type ContentManifestFile, +} from "./contentManifest"; +import { NativeContentStorage } from "./nativeContentStorage"; + +const CONTENT_STATE_KEY = "healer-man.content-state.v1"; +const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com"; +const STATIC_ANDROID_BUNDLED_PACKS = new Set(["core:ui", "core:equipment", "dungeon:wailing-caverns"]); + +export interface InstalledContentFile { + readonly packId: string; + readonly sha256: string; + readonly bytes: number; + readonly fileUri: string; + readonly extension: string; +} + +interface PersistedContentState { + readonly version: 1; + readonly packVersions: Readonly>; + readonly files: Readonly>; +} + +export interface ContentManagerSnapshot { + readonly revision: number; + readonly checking: boolean; + readonly manifest: ContentManifest | null; + readonly bundledManifest: ContentManifest | null; + readonly installingPackId: string | null; + readonly downloadedBytes: number; + readonly totalBytes: number; + readonly error: string; +} + +const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files: {} }; +let installedState = readInstalledState(); +let snapshot: ContentManagerSnapshot = { + revision: 0, + checking: false, + manifest: null, + bundledManifest: null, + installingPackId: null, + downloadedBytes: 0, + totalBytes: 0, + error: "", +}; +const listeners = new Set<() => void>(); +let initialization: Promise | null = null; + +function safeLocalStorage(): Storage | null { + try { return typeof window === "undefined" ? null : window.localStorage; } catch { return null; } +} + +function readInstalledState(): PersistedContentState { + try { + const value = JSON.parse(safeLocalStorage()?.getItem(CONTENT_STATE_KEY) ?? "null") as Partial | null; + if (!value || value.version !== 1 || !value.files || !value.packVersions) return EMPTY_STATE; + return { version: 1, files: value.files, packVersions: value.packVersions }; + } catch { + return EMPTY_STATE; + } +} + +function persistInstalledState(): void { + safeLocalStorage()?.setItem(CONTENT_STATE_KEY, JSON.stringify(installedState)); +} + +function publish(patch: Partial>): void { + snapshot = { ...snapshot, ...patch, revision: snapshot.revision + 1 }; + listeners.forEach((listener) => listener()); +} + +function contentOrigin(): string { + const configured = String(import.meta.env.VITE_CONTENT_BASE_URL ?? "").replace(/\/+$/, ""); + if (configured) return configured; + if (Capacitor.isNativePlatform()) return PRODUCTION_ORIGIN; + return typeof window === "undefined" ? "" : window.location.origin; +} + +function absoluteContentUrl(value: string): string { + return new URL(value, `${contentOrigin() || "http://localhost"}/`).toString(); +} + +function extensionForEntry(entry: ContentManifestFile): string { + const match = /(?:\.[A-Za-z0-9]+)(?:\?.*)?$/.exec(entry.url); + return match?.[0].split("?")[0].toLowerCase() ?? ".bin"; +} + +function bundledFiles(): Readonly> { + return snapshot.bundledManifest?.files ?? {}; +} + +function activeFileIdentities(): Readonly> { + return { ...bundledFiles(), ...installedState.files }; +} + +export function subscribeContentManager(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getContentManagerSnapshot(): ContentManagerSnapshot { + return snapshot; +} + +export function resolveContentUrl(logicalUrl: string): string { + if (!Capacitor.isNativePlatform()) return logicalUrl; + let pathname = logicalUrl; + try { pathname = new URL(logicalUrl, "https://local.invalid").pathname; } catch { /* preserve original */ } + const installed = installedState.files[pathname]; + return installed?.fileUri ? Capacitor.convertFileSrc(installed.fileUri) : logicalUrl; +} + +export function contentPackIsUsable(packId: string): boolean { + if (!Capacitor.isNativePlatform()) return true; + const installed = STATIC_ANDROID_BUNDLED_PACKS.has(packId) + || snapshot.bundledManifest?.packs.some((pack) => pack.id === packId) + || Boolean(installedState.packVersions[packId]); + if (!installed) return false; + const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId); + return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true; +} + +export function contentPackHasUpdate(packId: string): boolean { + if (!snapshot.manifest) return false; + const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId); + return changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0 + || Boolean(pack?.dependencies.some((dependency) => contentPackHasUpdate(dependency))); +} + +export async function checkForContentUpdates(): Promise { + publish({ checking: true, error: "" }); + try { + const response = await fetch(`${contentOrigin()}/content/manifest.json`, { cache: "no-cache" }); + if (!response.ok) throw new Error(`Content catalog request failed (${response.status}).`); + const manifest = parseContentManifest(await response.json()); + publish({ manifest, checking: false }); + return manifest; + } catch (error) { + publish({ checking: false, error: error instanceof Error ? error.message : "Content server is unavailable." }); + return null; + } +} + +async function loadBundledManifest(): Promise { + if (!Capacitor.isNativePlatform()) return; + try { + const response = await fetch("/content/bootstrap-manifest.json", { cache: "force-cache" }); + if (!response.ok) return; + publish({ bundledManifest: parseContentManifest(await response.json()) }); + } catch { + // The static bundled-pack list still keeps the starter dungeon available. + } +} + +export function initializeContentSystem(): Promise { + if (!initialization) { + initialization = (async () => { + await loadBundledManifest(); + if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates(); + })(); + } + return initialization; +} + +export async function installContentPack(packId: string): Promise { + const manifest = snapshot.manifest ?? await checkForContentUpdates(); + if (!manifest) throw new Error(snapshot.error || "Content server is unavailable."); + if (!appVersionMeetsMinimum(import.meta.env.VITE_APP_VERSION, manifest.minimumAppVersion)) { + throw new Error(`Healer Man ${manifest.minimumAppVersion} or newer is required for this content.`); + } + const pack = manifest.packs.find((candidate) => candidate.id === packId); + if (!pack) throw new Error(`Unknown content pack: ${packId}`); + if (!Capacitor.isNativePlatform()) return; + + for (const dependency of pack.dependencies) { + if (!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)) { + await installContentPack(dependency); + } + } + + const changes = changedFilesForPack(manifest, packId, activeFileIdentities()); + const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0); + const storage = await NativeContentStorage.getFreeBytes(); + const safetyReserve = 128 * 1024 * 1024; + if (storage.freeBytes < totalBytes + safetyReserve) { + const error = `Not enough free storage. ${Math.ceil((totalBytes + safetyReserve - storage.freeBytes) / 1024 / 1024)} MB more is required.`; + publish({ error }); + throw new Error(error); + } + publish({ installingPackId: packId, downloadedBytes: 0, totalBytes, error: "" }); + let completedBytes = 0; + const pendingFiles = { ...installedState.files }; + const progressHandle = await NativeContentStorage.addListener("downloadProgress", (progress) => { + publish({ downloadedBytes: Math.min(totalBytes, completedBytes + progress.downloadedBytes) }); + }); + + try { + for (const [logicalPath, entry] of changes) { + const extension = extensionForEntry(entry); + const result = await NativeContentStorage.download({ + url: absoluteContentUrl(entry.url), + logicalPath, + sha256: entry.sha256, + extension, + bytes: entry.bytes, + }); + pendingFiles[logicalPath] = { + packId, + sha256: entry.sha256, + bytes: entry.bytes, + extension, + fileUri: result.fileUri, + }; + completedBytes += entry.bytes; + publish({ downloadedBytes: completedBytes }); + } + + const latestPaths = new Set(filesForContentPack(manifest, packId).map(([logicalPath]) => logicalPath)); + for (const [logicalPath, installed] of Object.entries(pendingFiles)) { + if (installed.packId === packId && !latestPaths.has(logicalPath)) delete pendingFiles[logicalPath]; + } + installedState = { + version: 1, + files: pendingFiles, + packVersions: { ...installedState.packVersions, [packId]: manifest.version }, + }; + persistInstalledState(); + publish({ installingPackId: null, downloadedBytes: totalBytes, totalBytes, error: "" }); + } catch (error) { + publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." }); + throw error; + } finally { + await progressHandle.remove(); + } +} + +export async function removeContentPack(packId: string): Promise { + if (!Capacitor.isNativePlatform()) return; + const remainingFiles = { ...installedState.files }; + const targetFiles = Object.entries(remainingFiles).filter(([, entry]) => entry.packId === packId); + for (const [logicalPath, entry] of targetFiles) { + const shared = Object.entries(remainingFiles).some(([otherPath, other]) => ( + otherPath !== logicalPath && other.sha256 === entry.sha256 && other.extension === entry.extension && other.packId !== packId + )); + if (!shared) await NativeContentStorage.remove({ sha256: entry.sha256, extension: entry.extension }); + delete remainingFiles[logicalPath]; + } + const packVersions = { ...installedState.packVersions }; + delete packVersions[packId]; + installedState = { version: 1, files: remainingFiles, packVersions }; + persistInstalledState(); + publish({ error: "" }); +} diff --git a/src/content/contentManifest.test.ts b/src/content/contentManifest.test.ts new file mode 100644 index 00000000..667a5596 --- /dev/null +++ b/src/content/contentManifest.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + appVersionMeetsMinimum, + changedFilesForPack, + filesForContentPack, + parseContentManifest, + type ContentManifest, +} from "./contentManifest"; + +const manifest: ContentManifest = { + schemaVersion: 1, + version: "content-test", + createdAt: "2026-08-14T00:00:00.000Z", + minimumAppVersion: "0.1.0", + packs: [{ id: "dungeon:test", label: "Test", bytes: 3, fileCount: 2, bundledOnAndroid: false, dependencies: [] }], + files: { + "/assets/a.glb": { packId: "dungeon:test", url: "/content/objects/a.glb", sha256: "a".repeat(64), bytes: 1 }, + "/assets/b.glb": { packId: "dungeon:test", url: "/content/objects/b.glb", sha256: "b".repeat(64), bytes: 2 }, + }, +}; + +describe("content manifest", () => { + it("validates supported catalogs", () => { + expect(parseContentManifest(manifest)).toEqual(manifest); + expect(() => parseContentManifest({ ...manifest, schemaVersion: 99 })).toThrow(/unsupported/i); + }); + + it("compares semantic app versions", () => { + expect(appVersionMeetsMinimum("1.2.0", "1.1.9")).toBe(true); + expect(appVersionMeetsMinimum("1.2.0", "1.2.0")).toBe(true); + expect(appVersionMeetsMinimum("1.1.9", "1.2.0")).toBe(false); + }); + + it("downloads only files whose verified hash changed", () => { + expect(filesForContentPack(manifest, "dungeon:test")).toHaveLength(2); + expect(changedFilesForPack(manifest, "dungeon:test", { + "/assets/a.glb": { sha256: "a".repeat(64) }, + }).map(([logicalPath]) => logicalPath)).toEqual(["/assets/b.glb"]); + }); +}); diff --git a/src/content/contentManifest.ts b/src/content/contentManifest.ts new file mode 100644 index 00000000..090ab028 --- /dev/null +++ b/src/content/contentManifest.ts @@ -0,0 +1,107 @@ +export const CONTENT_MANIFEST_SCHEMA_VERSION = 1; + +export interface ContentManifestFile { + readonly packId: string; + readonly url: string; + readonly sha256: string; + readonly bytes: number; +} + +export interface ContentManifestPack { + readonly id: string; + readonly label: string; + readonly bytes: number; + readonly fileCount: number; + readonly bundledOnAndroid: boolean; + readonly dependencies: readonly string[]; +} + +export interface ContentManifest { + readonly schemaVersion: number; + readonly version: string; + readonly createdAt: string; + readonly minimumAppVersion: string; + readonly packs: readonly ContentManifestPack[]; + readonly files: Readonly>; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function parseContentManifest(value: unknown): ContentManifest { + if (!isRecord(value) || value.schemaVersion !== CONTENT_MANIFEST_SCHEMA_VERSION) { + throw new Error("This content catalog uses an unsupported schema."); + } + if (typeof value.version !== "string" || typeof value.createdAt !== "string" || typeof value.minimumAppVersion !== "string") { + throw new Error("The content catalog is missing version information."); + } + if (!Array.isArray(value.packs) || !isRecord(value.files)) { + throw new Error("The content catalog is malformed."); + } + const packs = value.packs.map((pack) => { + if (!isRecord(pack) + || typeof pack.id !== "string" + || typeof pack.label !== "string" + || typeof pack.bytes !== "number" + || typeof pack.fileCount !== "number" + || typeof pack.bundledOnAndroid !== "boolean" + || !Array.isArray(pack.dependencies) + || !pack.dependencies.every((dependency) => typeof dependency === "string")) { + throw new Error("The content catalog contains a malformed pack."); + } + return pack as unknown as ContentManifestPack; + }); + const files = Object.fromEntries(Object.entries(value.files).map(([logicalPath, entry]) => { + if (!logicalPath.startsWith("/") + || !isRecord(entry) + || typeof entry.packId !== "string" + || typeof entry.url !== "string" + || !/^[a-f0-9]{64}$/.test(String(entry.sha256)) + || typeof entry.bytes !== "number" + || !Number.isSafeInteger(entry.bytes) + || entry.bytes < 0) { + throw new Error(`The content catalog contains a malformed file: ${logicalPath}`); + } + return [logicalPath, entry as unknown as ContentManifestFile]; + })); + return { ...value, packs, files } as unknown as ContentManifest; +} + +function numericVersion(value: string): readonly number[] { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.trim()); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0]; +} + +export function appVersionMeetsMinimum(current: string, minimum: string): boolean { + const left = numericVersion(current); + const right = numericVersion(minimum); + for (let index = 0; index < 3; index += 1) { + if (left[index] > right[index]) return true; + if (left[index] < right[index]) return false; + } + return true; +} + +export function filesForContentPack( + manifest: ContentManifest, + packId: string, +): readonly (readonly [string, ContentManifestFile])[] { + return Object.entries(manifest.files) + .filter(([, entry]) => entry.packId === packId) + .sort(([left], [right]) => left.localeCompare(right)); +} + +export interface ContentFileIdentity { + readonly sha256: string; +} + +export function changedFilesForPack( + manifest: ContentManifest, + packId: string, + activeFiles: Readonly>, +): readonly (readonly [string, ContentManifestFile])[] { + return filesForContentPack(manifest, packId).filter(([logicalPath, entry]) => ( + activeFiles[logicalPath]?.sha256 !== entry.sha256 + )); +} diff --git a/src/content/nativeContentStorage.ts b/src/content/nativeContentStorage.ts new file mode 100644 index 00000000..c4577cc6 --- /dev/null +++ b/src/content/nativeContentStorage.ts @@ -0,0 +1,39 @@ +import { registerPlugin, type PluginListenerHandle } from "@capacitor/core"; + +export interface ContentDownloadProgress { + readonly logicalPath: string; + readonly downloadedBytes: number; + readonly totalBytes: number; +} + +export interface NativeContentFileResult { + readonly fileUri: string; + readonly bytes: number; + readonly reused: boolean; +} + +interface DownloadOptions { + readonly url: string; + readonly logicalPath: string; + readonly sha256: string; + readonly extension: string; + readonly bytes: number; +} + +interface ObjectOptions { + readonly sha256: string; + readonly extension: string; +} + +interface NativeContentStoragePlugin { + download(options: DownloadOptions): Promise; + remove(options: ObjectOptions): Promise; + has(options: ObjectOptions & { readonly bytes: number }): Promise<{ readonly exists: boolean; readonly fileUri?: string }>; + getFreeBytes(): Promise<{ readonly freeBytes: number }>; + addListener( + eventName: "downloadProgress", + listener: (event: ContentDownloadProgress) => void, + ): Promise; +} + +export const NativeContentStorage = registerPlugin("ContentStorage"); diff --git a/src/content/useContentManager.ts b/src/content/useContentManager.ts new file mode 100644 index 00000000..9970f11c --- /dev/null +++ b/src/content/useContentManager.ts @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from "react"; +import { + getContentManagerSnapshot, + subscribeContentManager, +} from "./contentManager"; + +export function useContentManagerSnapshot() { + return useSyncExternalStore( + subscribeContentManager, + getContentManagerSnapshot, + getContentManagerSnapshot, + ); +}