Include content delivery runtime
This commit is contained in:
+1
-1
@@ -17,6 +17,6 @@ runewaker-rig-probe-*/
|
|||||||
data/runtime/
|
data/runtime/
|
||||||
backups/
|
backups/
|
||||||
releases/
|
releases/
|
||||||
content/
|
/content/
|
||||||
dist-android/
|
dist-android/
|
||||||
.android-public/
|
.android-public/
|
||||||
|
|||||||
@@ -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}`);
|
||||||
@@ -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")}`);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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<Record<string, string>>;
|
||||||
|
readonly files: Readonly<Record<string, InstalledContentFile>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> | 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<PersistedContentState> | 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<Omit<ContentManagerSnapshot, "revision">>): 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<Record<string, { readonly sha256: string }>> {
|
||||||
|
return snapshot.bundledManifest?.files ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeFileIdentities(): Readonly<Record<string, { readonly sha256: string }>> {
|
||||||
|
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<ContentManifest | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (!initialization) {
|
||||||
|
initialization = (async () => {
|
||||||
|
await loadBundledManifest();
|
||||||
|
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return initialization;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installContentPack(packId: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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: "" });
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<string, ContentManifestFile>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
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<Record<string, ContentFileIdentity>>,
|
||||||
|
): readonly (readonly [string, ContentManifestFile])[] {
|
||||||
|
return filesForContentPack(manifest, packId).filter(([logicalPath, entry]) => (
|
||||||
|
activeFiles[logicalPath]?.sha256 !== entry.sha256
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -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<NativeContentFileResult>;
|
||||||
|
remove(options: ObjectOptions): Promise<void>;
|
||||||
|
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<PluginListenerHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NativeContentStorage = registerPlugin<NativeContentStoragePlugin>("ContentStorage");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import {
|
||||||
|
getContentManagerSnapshot,
|
||||||
|
subscribeContentManager,
|
||||||
|
} from "./contentManager";
|
||||||
|
|
||||||
|
export function useContentManagerSnapshot() {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeContentManager,
|
||||||
|
getContentManagerSnapshot,
|
||||||
|
getContentManagerSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user