701 lines
30 KiB
TypeScript
701 lines
30 KiB
TypeScript
import { Capacitor } from "@capacitor/core";
|
|
import {
|
|
appVersionMeetsMinimum,
|
|
changedFilesForPack,
|
|
contentPackIdsForLogicalUrls,
|
|
filesForContentPack,
|
|
parseContentManifest,
|
|
type ContentManifest,
|
|
type ContentManifestFile,
|
|
} from "./contentManifest";
|
|
import { NativeContentStorage } from "./nativeContentStorage";
|
|
import contentPolicy from "../../shared/content-policy.json";
|
|
import { dungeonContentPackId } from "../game/dungeonContentSource";
|
|
|
|
export const CONTENT_STATE_KEY = "healer-man.content-state.v2";
|
|
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
|
const STATIC_BUNDLED_PACKS = new Set(contentPolicy.bootstrapPackIds);
|
|
|
|
export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
|
|
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
|
|
const BROWSER_CONTENT_SHA256_HEADER = "x-healer-man-content-sha256";
|
|
const BROWSER_CONTENT_BYTES_HEADER = "x-healer-man-content-bytes";
|
|
const BROWSER_CONTENT_OBJECT_HEADER = "x-healer-man-content-object";
|
|
const BROWSER_CONTENT_ENCODING_HEADER = "x-healer-man-content-encoding";
|
|
|
|
export interface InstalledContentFile {
|
|
readonly packId: string;
|
|
readonly sha256: string;
|
|
readonly bytes: number;
|
|
readonly fileUri: string;
|
|
readonly extension: string;
|
|
}
|
|
|
|
interface InstalledContentFileIdentity {
|
|
readonly sha256: string;
|
|
readonly bytes: number;
|
|
}
|
|
|
|
interface InstalledContentPackReceipt {
|
|
readonly manifestVersion: string;
|
|
readonly dependencies: readonly string[];
|
|
readonly files: Readonly<Record<string, InstalledContentFileIdentity>>;
|
|
}
|
|
|
|
interface PersistedContentState {
|
|
readonly version: 2;
|
|
readonly packs: Readonly<Record<string, InstalledContentPackReceipt>>;
|
|
readonly files: Readonly<Record<string, InstalledContentFile>>;
|
|
}
|
|
|
|
export interface ContentManagerSnapshot {
|
|
readonly revision: number;
|
|
readonly installedRevision: 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: 2, packs: {}, files: {} };
|
|
let installedState = readInstalledState();
|
|
let snapshot: ContentManagerSnapshot = {
|
|
revision: 0,
|
|
installedRevision: 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;
|
|
let installedStorageValidation: Promise<void> | null = null;
|
|
let installedStorageValidationComplete = Object.keys(installedState.packs).length === 0;
|
|
const validatedInstalledFiles = new Set<string>();
|
|
const usablePackCache = new Map<string, boolean>();
|
|
|
|
function safeLocalStorage(): Storage | null {
|
|
try { return typeof window === "undefined" ? null : window.localStorage; } catch { return null; }
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function isSha256(value: unknown): value is string {
|
|
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
}
|
|
|
|
function isByteCount(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
}
|
|
|
|
function readInstalledState(): PersistedContentState {
|
|
try {
|
|
const value = JSON.parse(safeLocalStorage()?.getItem(CONTENT_STATE_KEY) ?? "null") as unknown;
|
|
if (!isRecord(value) || value.version !== 2 || !isRecord(value.files) || !isRecord(value.packs)) {
|
|
return EMPTY_STATE;
|
|
}
|
|
const files: Record<string, InstalledContentFile> = {};
|
|
for (const [logicalPath, entry] of Object.entries(value.files)) {
|
|
if (!logicalPath.startsWith("/")
|
|
|| !isRecord(entry)
|
|
|| typeof entry.packId !== "string"
|
|
|| !isSha256(entry.sha256)
|
|
|| !isByteCount(entry.bytes)
|
|
|| typeof entry.fileUri !== "string"
|
|
|| !entry.fileUri
|
|
|| typeof entry.extension !== "string"
|
|
|| !entry.extension.startsWith(".")) return EMPTY_STATE;
|
|
files[logicalPath] = entry as unknown as InstalledContentFile;
|
|
}
|
|
const packs: Record<string, InstalledContentPackReceipt> = {};
|
|
for (const [packId, receipt] of Object.entries(value.packs)) {
|
|
if (!packId
|
|
|| !isRecord(receipt)
|
|
|| typeof receipt.manifestVersion !== "string"
|
|
|| !receipt.manifestVersion
|
|
|| !Array.isArray(receipt.dependencies)
|
|
|| !receipt.dependencies.every((dependency) => typeof dependency === "string")
|
|
|| !isRecord(receipt.files)) return EMPTY_STATE;
|
|
const receiptFiles: Record<string, InstalledContentFileIdentity> = {};
|
|
for (const [logicalPath, identity] of Object.entries(receipt.files)) {
|
|
if (!logicalPath.startsWith("/")
|
|
|| !isRecord(identity)
|
|
|| !isSha256(identity.sha256)
|
|
|| !isByteCount(identity.bytes)) return EMPTY_STATE;
|
|
receiptFiles[logicalPath] = { sha256: identity.sha256, bytes: identity.bytes };
|
|
}
|
|
packs[packId] = {
|
|
manifestVersion: receipt.manifestVersion,
|
|
dependencies: [...receipt.dependencies],
|
|
files: receiptFiles,
|
|
};
|
|
}
|
|
return { version: 2, files, packs };
|
|
} catch {
|
|
return EMPTY_STATE;
|
|
}
|
|
}
|
|
|
|
function persistInstalledState(): void {
|
|
safeLocalStorage()?.setItem(CONTENT_STATE_KEY, JSON.stringify(installedState));
|
|
}
|
|
|
|
function publish(patch: Partial<Omit<ContentManagerSnapshot, "revision">>): void {
|
|
// Every receipt/storage/manifest change publishes before notifying consumers.
|
|
// Cache both usable and unavailable packs until the next content revision.
|
|
usablePackCache.clear();
|
|
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 browserCacheRequest(logicalPath: string): Request {
|
|
const appOrigin = typeof window === "undefined" ? "http://localhost" : window.location.origin;
|
|
return new Request(new URL(logicalPath, `${appOrigin}/`).toString(), { method: "GET" });
|
|
}
|
|
|
|
export function browserContentObjectPath(entry: { readonly sha256: string; readonly extension?: string; readonly url?: string }): string {
|
|
const extension = entry.extension ?? extensionForEntry(entry as ContentManifestFile);
|
|
return `/content/cache-objects/${entry.sha256}${extension}`;
|
|
}
|
|
|
|
function verifiedCachedResponse(response: Response | undefined, entry: { readonly sha256: string; readonly bytes: number }): boolean {
|
|
return response?.headers.get(BROWSER_CONTENT_SHA256_HEADER) === entry.sha256
|
|
&& response.headers.get(BROWSER_CONTENT_BYTES_HEADER) === String(entry.bytes);
|
|
}
|
|
|
|
async function cachedLogicalObject(cache: Cache, logicalPath: string): Promise<Response | undefined> {
|
|
const alias = await cache.match(browserCacheRequest(logicalPath));
|
|
const objectPath = alias?.headers.get(BROWSER_CONTENT_OBJECT_HEADER);
|
|
if (!objectPath) return alias;
|
|
return objectPath.startsWith("/content/cache-objects/") ? cache.match(browserCacheRequest(objectPath)) : undefined;
|
|
}
|
|
|
|
function browserCacheStorage(): CacheStorage | null {
|
|
return typeof caches === "undefined" ? null : caches;
|
|
}
|
|
|
|
async function validateInstalledContentStorage(): Promise<void> {
|
|
if (!installedStorageValidation) {
|
|
installedStorageValidation = (async () => {
|
|
const validFiles: Record<string, InstalledContentFile> = {};
|
|
if (Capacitor.isNativePlatform()) {
|
|
for (const [logicalPath, entry] of Object.entries(installedState.files)) {
|
|
try {
|
|
const result = await NativeContentStorage.has({
|
|
sha256: entry.sha256,
|
|
extension: entry.extension,
|
|
bytes: entry.bytes,
|
|
});
|
|
if (!result.exists) continue;
|
|
validFiles[logicalPath] = {
|
|
...entry,
|
|
fileUri: result.fileUri || entry.fileUri,
|
|
};
|
|
} catch {
|
|
// A file that cannot be verified must not make its pack launchable.
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
const storage = browserCacheStorage();
|
|
const active = storage ? await storage.open(BROWSER_CONTENT_CACHE_NAME) : null;
|
|
if (active) {
|
|
for (const [logicalPath, entry] of Object.entries(installedState.files)) {
|
|
const cached = await cachedLogicalObject(active, logicalPath);
|
|
if (verifiedCachedResponse(cached, entry)) {
|
|
validFiles[logicalPath] = entry;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Cache access failure is handled as unavailable content, never as a valid install.
|
|
}
|
|
}
|
|
|
|
validatedInstalledFiles.clear();
|
|
Object.keys(validFiles).forEach((logicalPath) => validatedInstalledFiles.add(logicalPath));
|
|
const filesChanged = Object.keys(validFiles).length !== Object.keys(installedState.files).length
|
|
|| Object.entries(validFiles).some(([logicalPath, entry]) => (
|
|
installedState.files[logicalPath]?.fileUri !== entry.fileUri
|
|
));
|
|
installedState = { ...installedState, files: validFiles };
|
|
installedStorageValidationComplete = true;
|
|
if (filesChanged) persistInstalledState();
|
|
publish({ installedRevision: snapshot.installedRevision + 1 });
|
|
})();
|
|
}
|
|
await installedStorageValidation;
|
|
}
|
|
|
|
async function registerBrowserContentWorker(): Promise<void> {
|
|
if (Capacitor.isNativePlatform()
|
|
|| import.meta.env.DEV
|
|
|| typeof navigator === "undefined"
|
|
|| !("serviceWorker" in navigator)) return;
|
|
try {
|
|
await navigator.serviceWorker.register(BROWSER_CONTENT_WORKER_URL, {
|
|
scope: "/",
|
|
updateViaCache: "none",
|
|
});
|
|
const registration = await navigator.serviceWorker.ready;
|
|
registration.active?.postMessage({ type: "refresh-content-bootstrap" });
|
|
} catch (error) {
|
|
console.warn("Browser content cache worker could not be registered.", error);
|
|
}
|
|
}
|
|
|
|
function extensionForEntry(entry: ContentManifestFile): string {
|
|
const match = /(?:\.[A-Za-z0-9]+)(?:\?.*)?$/.exec(entry.url);
|
|
return match?.[0].split("?")[0].toLowerCase() ?? ".bin";
|
|
}
|
|
|
|
function packReceiptForManifest(manifest: ContentManifest, packId: string): InstalledContentPackReceipt {
|
|
const pack = manifest.packs.find((candidate) => candidate.id === packId);
|
|
if (!pack) throw new Error(`Unknown content pack: ${packId}`);
|
|
return {
|
|
manifestVersion: manifest.version,
|
|
dependencies: [...pack.dependencies],
|
|
files: Object.fromEntries(filesForContentPack(manifest, packId).map(([logicalPath, entry]) => [
|
|
logicalPath,
|
|
{ sha256: entry.sha256, bytes: entry.bytes },
|
|
])),
|
|
};
|
|
}
|
|
|
|
function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> {
|
|
if (snapshot.bundledManifest) return snapshot.bundledManifest.files;
|
|
if (!snapshot.manifest) return {};
|
|
const bundledPacks = STATIC_BUNDLED_PACKS;
|
|
return Object.fromEntries(Object.entries(snapshot.manifest.files).filter(([, entry]) => (
|
|
bundledPacks.has(entry.packId)
|
|
)));
|
|
}
|
|
|
|
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 && validatedInstalledFiles.has(pathname)
|
|
? Capacitor.convertFileSrc(installed.fileUri)
|
|
: logicalUrl;
|
|
}
|
|
|
|
export function contentPackIsBundled(packId: string): boolean {
|
|
return STATIC_BUNDLED_PACKS.has(packId)
|
|
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId && pack.bundledOnAndroid));
|
|
}
|
|
|
|
export function contentPackIsInstalled(packId: string): boolean {
|
|
return contentPackIsBundled(packId) || Boolean(installedState.packs[packId]);
|
|
}
|
|
|
|
function matchingReceiptFiles(
|
|
packId: string,
|
|
receipt: InstalledContentPackReceipt,
|
|
expectedFiles: Readonly<Record<string, InstalledContentFileIdentity>>,
|
|
): boolean {
|
|
const expectedEntries = Object.entries(expectedFiles);
|
|
if (Object.keys(receipt.files).length !== expectedEntries.length) return false;
|
|
return expectedEntries.every(([logicalPath, expected]) => {
|
|
const recorded = receipt.files[logicalPath];
|
|
if (recorded?.sha256 === expected.sha256 && recorded.bytes === expected.bytes
|
|
&& snapshot.bundledManifest?.files[logicalPath]?.sha256 === expected.sha256) return true;
|
|
const installed = installedState.files[logicalPath];
|
|
return recorded?.sha256 === expected.sha256
|
|
&& recorded.bytes === expected.bytes
|
|
&& installed?.packId === packId
|
|
&& installed.sha256 === expected.sha256
|
|
&& installed.bytes === expected.bytes
|
|
&& validatedInstalledFiles.has(logicalPath);
|
|
});
|
|
}
|
|
|
|
function contentPackIsUsableInternal(packId: string, visiting: ReadonlySet<string>): boolean {
|
|
if (contentPackIsBundled(packId)) return true;
|
|
if (!installedStorageValidationComplete || visiting.has(packId)) return false;
|
|
const receipt = installedState.packs[packId];
|
|
if (!receipt) return false;
|
|
|
|
const manifestPack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
|
|
if (snapshot.manifest && !manifestPack) return false;
|
|
const dependencies = manifestPack?.dependencies ?? receipt.dependencies;
|
|
if (dependencies.length !== receipt.dependencies.length
|
|
|| dependencies.some((dependency, index) => receipt.dependencies[index] !== dependency)) return false;
|
|
const expectedFiles = manifestPack
|
|
? Object.fromEntries(filesForContentPack(snapshot.manifest as ContentManifest, packId).map(([logicalPath, entry]) => [
|
|
logicalPath,
|
|
{ sha256: entry.sha256, bytes: entry.bytes },
|
|
]))
|
|
: receipt.files;
|
|
if (!matchingReceiptFiles(packId, receipt, expectedFiles)) return false;
|
|
|
|
const nextVisiting = new Set(visiting);
|
|
nextVisiting.add(packId);
|
|
return dependencies.every((dependency) => contentPackIsUsableInternal(dependency, nextVisiting));
|
|
}
|
|
|
|
export function contentPackIsUsable(packId: string): boolean {
|
|
const cached = usablePackCache.get(packId);
|
|
if (cached !== undefined) return cached;
|
|
const usable = contentPackIsUsableInternal(packId, new Set());
|
|
usablePackCache.set(packId, usable);
|
|
return usable;
|
|
}
|
|
|
|
/** Vite serves the complete source asset tree in development; shipped builds require an installed pack. */
|
|
export function dungeonContentIsUsable(dungeonId: string): boolean {
|
|
return import.meta.env.DEV || contentPackIsUsable(dungeonContentPackId(dungeonId));
|
|
}
|
|
|
|
export function contentPackHasUpdate(packId: string): boolean {
|
|
if (!snapshot.manifest || !contentPackIsInstalled(packId)) return false;
|
|
const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId);
|
|
return Boolean(!contentPackIsBundled(packId) && !contentPackIsUsable(packId))
|
|
|| changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|
|
|| Boolean(!contentPackIsBundled(packId)
|
|
&& pack?.dependencies.some((dependency) => (
|
|
!contentPackIsUsable(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() && import.meta.env.VITE_CONTENT_MODE !== "downloadable") return;
|
|
try {
|
|
const response = await fetch("/content/bootstrap-manifest.json", { cache: Capacitor.isNativePlatform() ? "force-cache" : "no-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 Promise.all([
|
|
loadBundledManifest(),
|
|
registerBrowserContentWorker(),
|
|
validateInstalledContentStorage(),
|
|
]);
|
|
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
|
|
})();
|
|
}
|
|
return initialization;
|
|
}
|
|
|
|
async function sha256Bytes(bytes: ArrayBuffer): Promise<string> {
|
|
if (!globalThis.crypto?.subtle) {
|
|
throw new Error("This browser cannot verify downloaded content.");
|
|
}
|
|
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
async function installBrowserContentPack(
|
|
packId: string,
|
|
manifest: ContentManifest,
|
|
changes: ReturnType<typeof changedFilesForPack>,
|
|
): Promise<void> {
|
|
const storage = browserCacheStorage();
|
|
if (!storage) throw new Error("Offline content storage is unavailable in this browser.");
|
|
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
|
|
const objects = new Map<string, { logicalPath: string; entry: ContentManifestFile; cached?: Response }>();
|
|
for (const [logicalPath, entry] of changes) {
|
|
const objectPath = browserContentObjectPath(entry);
|
|
if (objects.has(objectPath)) continue;
|
|
let cached = await active.match(browserCacheRequest(objectPath));
|
|
if (!verifiedCachedResponse(cached, entry)) cached = await cachedLogicalObject(active, logicalPath);
|
|
objects.set(objectPath, { logicalPath, entry, cached: verifiedCachedResponse(cached, entry) ? cached : undefined });
|
|
}
|
|
const totalBytes = [...objects.values()].reduce((sum, object) => sum + (object.cached ? 0 : object.entry.download?.bytes ?? object.entry.bytes), 0);
|
|
const stagingName = `${BROWSER_CONTENT_CACHE_NAME}-staging-${encodeURIComponent(packId)}`;
|
|
await storage.delete(stagingName);
|
|
const staging = await storage.open(stagingName);
|
|
publish({ installingPackId: packId, downloadedBytes: 0, totalBytes, error: "" });
|
|
let completedBytes = 0;
|
|
try {
|
|
for (const [objectPath, { logicalPath, entry, cached }] of objects) {
|
|
if (cached) {
|
|
await staging.put(browserCacheRequest(objectPath), cached);
|
|
continue;
|
|
}
|
|
const download = entry.download ?? entry;
|
|
const response = await fetch(absoluteContentUrl(download.url), { cache: "no-store" });
|
|
if (!response.ok) throw new Error(`Content request failed (${response.status}): ${logicalPath}`);
|
|
const stored = await response.arrayBuffer();
|
|
if (stored.byteLength !== download.bytes) throw new Error(`Downloaded content has the wrong size: ${logicalPath}`);
|
|
if (await sha256Bytes(stored) !== download.sha256) throw new Error(`Downloaded content failed verification: ${logicalPath}`);
|
|
if (entry.download) {
|
|
const expanded = await new Response(new Blob([stored]).stream().pipeThrough(new DecompressionStream("gzip"))).arrayBuffer();
|
|
if (expanded.byteLength !== entry.bytes || await sha256Bytes(expanded) !== entry.sha256) {
|
|
throw new Error(`Expanded content failed verification: ${logicalPath}`);
|
|
}
|
|
}
|
|
const headers = new Headers();
|
|
if (!entry.download && response.headers.get("content-type")) headers.set("content-type", response.headers.get("content-type")!);
|
|
headers.set(BROWSER_CONTENT_SHA256_HEADER, entry.sha256);
|
|
headers.set(BROWSER_CONTENT_BYTES_HEADER, String(entry.bytes));
|
|
if (entry.download) headers.set(BROWSER_CONTENT_ENCODING_HEADER, "gzip");
|
|
await staging.put(browserCacheRequest(objectPath), new Response(stored, { headers, status: 200 }));
|
|
completedBytes += download.bytes;
|
|
publish({ downloadedBytes: completedBytes });
|
|
}
|
|
// Commit every verified object before switching any logical URL to the new version.
|
|
for (const objectPath of objects.keys()) {
|
|
const cached = await staging.match(browserCacheRequest(objectPath));
|
|
if (!cached) throw new Error(`Verified content was not staged: ${objectPath}`);
|
|
await active.put(browserCacheRequest(objectPath), cached);
|
|
}
|
|
for (const [logicalPath, entry] of changes) {
|
|
await active.put(browserCacheRequest(logicalPath), new Response(null, { headers: {
|
|
[BROWSER_CONTENT_OBJECT_HEADER]: browserContentObjectPath(entry),
|
|
[BROWSER_CONTENT_SHA256_HEADER]: entry.sha256,
|
|
[BROWSER_CONTENT_BYTES_HEADER]: String(entry.bytes),
|
|
} }));
|
|
}
|
|
const previousFiles = installedState.files;
|
|
const pendingFiles = { ...previousFiles };
|
|
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)) continue;
|
|
await active.delete(browserCacheRequest(logicalPath));
|
|
delete pendingFiles[logicalPath];
|
|
validatedInstalledFiles.delete(logicalPath);
|
|
}
|
|
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
|
|
if (snapshot.bundledManifest?.files[logicalPath]?.sha256 === entry.sha256) continue;
|
|
pendingFiles[logicalPath] = { packId, sha256: entry.sha256, bytes: entry.bytes,
|
|
extension: extensionForEntry(entry), fileUri: absoluteContentUrl(logicalPath) };
|
|
validatedInstalledFiles.add(logicalPath);
|
|
}
|
|
installedState = { version: 2, files: pendingFiles, packs: {
|
|
...installedState.packs, [packId]: packReceiptForManifest(manifest, packId),
|
|
} };
|
|
persistInstalledState();
|
|
const retained = new Set(Object.values(pendingFiles).map(browserContentObjectPath));
|
|
for (const previous of Object.values(previousFiles)) {
|
|
const objectPath = browserContentObjectPath(previous);
|
|
if (!retained.has(objectPath)) await active.delete(browserCacheRequest(objectPath));
|
|
}
|
|
publish({ installingPackId: null, downloadedBytes: totalBytes, totalBytes, error: "", installedRevision: snapshot.installedRevision + 1 });
|
|
} catch (error) {
|
|
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
|
|
throw error;
|
|
} finally {
|
|
await storage.delete(stagingName).catch(() => false);
|
|
}
|
|
}
|
|
|
|
export async function installContentPack(packId: string): Promise<void> {
|
|
await validateInstalledContentStorage();
|
|
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 (!contentPackIsBundled(packId)) {
|
|
for (const dependency of pack.dependencies) {
|
|
if (!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)) {
|
|
await installContentPack(dependency);
|
|
}
|
|
}
|
|
}
|
|
|
|
const changes = changedFilesForPack(manifest, packId, activeFileIdentities());
|
|
if (!Capacitor.isNativePlatform()) {
|
|
await installBrowserContentPack(packId, manifest, changes);
|
|
return;
|
|
}
|
|
const existingObjects = new Set(Object.entries(installedState.files)
|
|
.filter(([logicalPath]) => validatedInstalledFiles.has(logicalPath))
|
|
.map(([, entry]) => browserContentObjectPath(entry)));
|
|
const transferObjects = new Map(changes.map(([, entry]) => [browserContentObjectPath(entry), entry]));
|
|
const missingObjects = [...transferObjects].filter(([key]) => !existingObjects.has(key));
|
|
const totalBytes = missingObjects.reduce((total, [, entry]) => total + (entry.download?.bytes ?? entry.bytes), 0);
|
|
const installedBytes = missingObjects.reduce((total, [, entry]) => total + entry.bytes, 0);
|
|
const storage = await NativeContentStorage.getFreeBytes();
|
|
const safetyReserve = 128 * 1024 * 1024;
|
|
if (storage.freeBytes < installedBytes + safetyReserve) {
|
|
const error = `Not enough free storage. ${Math.ceil((installedBytes + 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.download?.url ?? entry.url),
|
|
logicalPath,
|
|
sha256: entry.sha256,
|
|
extension,
|
|
bytes: entry.bytes,
|
|
...(entry.download ? { encoding: entry.download.encoding, downloadSha256: entry.download.sha256, downloadBytes: entry.download.bytes } : {}),
|
|
});
|
|
pendingFiles[logicalPath] = {
|
|
packId,
|
|
sha256: entry.sha256,
|
|
bytes: entry.bytes,
|
|
extension,
|
|
fileUri: result.fileUri,
|
|
};
|
|
const objectKey = browserContentObjectPath(entry);
|
|
if (!existingObjects.has(objectKey)) completedBytes += entry.download?.bytes ?? entry.bytes;
|
|
existingObjects.add(objectKey);
|
|
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];
|
|
validatedInstalledFiles.delete(logicalPath);
|
|
}
|
|
}
|
|
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
|
|
const installed = pendingFiles[logicalPath];
|
|
if (!installed && snapshot.bundledManifest?.files[logicalPath]?.sha256 === entry.sha256) continue;
|
|
if (!installed) throw new Error(`Verified content is missing after installation: ${logicalPath}`);
|
|
pendingFiles[logicalPath] = {
|
|
...installed,
|
|
packId,
|
|
sha256: entry.sha256,
|
|
bytes: entry.bytes,
|
|
extension: extensionForEntry(entry),
|
|
};
|
|
validatedInstalledFiles.add(logicalPath);
|
|
}
|
|
installedState = {
|
|
version: 2,
|
|
files: pendingFiles,
|
|
packs: {
|
|
...installedState.packs,
|
|
[packId]: packReceiptForManifest(manifest, packId),
|
|
},
|
|
};
|
|
persistInstalledState();
|
|
publish({
|
|
installingPackId: null,
|
|
downloadedBytes: totalBytes,
|
|
totalBytes,
|
|
error: "",
|
|
installedRevision: snapshot.installedRevision + 1,
|
|
});
|
|
} catch (error) {
|
|
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
|
|
throw error;
|
|
} finally {
|
|
await progressHandle.remove();
|
|
}
|
|
}
|
|
|
|
/** Installs only the packs owning the selected run's logical model URLs. */
|
|
export async function installContentForLogicalUrls(logicalUrls: readonly string[]): Promise<void> {
|
|
await validateInstalledContentStorage();
|
|
const logicalPaths = logicalUrls.map((logicalUrl) => {
|
|
try { return new URL(logicalUrl, "https://healer-man.invalid").pathname; } catch { return logicalUrl; }
|
|
});
|
|
const alreadyAvailable = logicalPaths.every((logicalPath) => (
|
|
Boolean(snapshot.bundledManifest?.files[logicalPath])
|
|
|| Boolean(installedState.files[logicalPath] && validatedInstalledFiles.has(logicalPath))
|
|
));
|
|
if (alreadyAvailable) return;
|
|
const manifest = snapshot.manifest ?? await checkForContentUpdates();
|
|
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
|
|
for (const packId of contentPackIdsForLogicalUrls(manifest, logicalUrls)) {
|
|
if (!contentPackIsUsable(packId) || contentPackHasUpdate(packId)) {
|
|
await installContentPack(packId);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function removeContentPack(packId: string): Promise<void> {
|
|
if (contentPackIsBundled(packId)) return;
|
|
const remainingFiles = { ...installedState.files };
|
|
const targetFiles = Object.entries(remainingFiles).filter(([, entry]) => entry.packId === packId);
|
|
if (Capacitor.isNativePlatform()) {
|
|
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];
|
|
}
|
|
} else {
|
|
const storage = browserCacheStorage();
|
|
if (!storage) throw new Error("Offline content storage is unavailable in this browser.");
|
|
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
|
|
for (const [logicalPath] of targetFiles) {
|
|
await active.delete(browserCacheRequest(logicalPath));
|
|
delete remainingFiles[logicalPath];
|
|
}
|
|
const retainedObjects = new Set(Object.values(remainingFiles).map(browserContentObjectPath));
|
|
for (const [, entry] of targetFiles) {
|
|
const objectPath = browserContentObjectPath(entry);
|
|
if (!retainedObjects.has(objectPath)) await active.delete(browserCacheRequest(objectPath));
|
|
}
|
|
}
|
|
targetFiles.forEach(([logicalPath]) => validatedInstalledFiles.delete(logicalPath));
|
|
const packs = { ...installedState.packs };
|
|
delete packs[packId];
|
|
installedState = { version: 2, files: remainingFiles, packs };
|
|
persistInstalledState();
|
|
publish({ error: "", installedRevision: snapshot.installedRevision + 1 });
|
|
}
|