Include content delivery runtime

This commit is contained in:
phenom committed 2026-08-15 21:11:43 -04:00
1 parent 4d0980a389
commit b5c9bce1aa
9 files changed
+809 -1

No files matched your search

+260
View File
@@ -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: "" });
}