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", "core:character-basic", "core:runtime", "core:arenas", "dungeon:wailing-caverns", ]); const STATIC_BROWSER_BUNDLED_PACKS = new Set([ "core:ui", "core:equipment", "core:character-basic", "core:runtime", "core:arenas", "dungeon:wailing-caverns", ]); export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1"; export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js"; 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 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: 1, packVersions: {}, 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 | 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 browserCacheRequest(logicalPath: string): Request { const appOrigin = typeof window === "undefined" ? "http://localhost" : window.location.origin; return new Request(new URL(logicalPath, `${appOrigin}/`).toString(), { method: "GET" }); } function browserCacheStorage(): CacheStorage | null { return typeof caches === "undefined" ? null : caches; } async function registerBrowserContentWorker(): Promise { 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", }); await navigator.serviceWorker.ready; } 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 bundledFiles(): Readonly> { if (snapshot.bundledManifest) return snapshot.bundledManifest.files; if (!snapshot.manifest) return {}; const bundledPacks = Capacitor.isNativePlatform() ? STATIC_ANDROID_BUNDLED_PACKS : STATIC_BROWSER_BUNDLED_PACKS; return Object.fromEntries(Object.entries(snapshot.manifest.files).filter(([, entry]) => ( bundledPacks.has(entry.packId) ))); } 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 contentPackIsBundled(packId: string): boolean { if (!Capacitor.isNativePlatform()) return STATIC_BROWSER_BUNDLED_PACKS.has(packId); return STATIC_ANDROID_BUNDLED_PACKS.has(packId) || Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId)); } export function contentPackIsInstalled(packId: string): boolean { return contentPackIsBundled(packId) || Boolean(installedState.packVersions[packId]); } export function contentPackIsUsable(packId: string): boolean { if (contentPackIsBundled(packId)) return true; if (!installedState.packVersions[packId]) 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 || !contentPackIsInstalled(packId)) return false; const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId); return changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0 || Boolean(!contentPackIsBundled(packId) && pack?.dependencies.some((dependency) => ( !contentPackIsUsable(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() && import.meta.env.VITE_CONTENT_MODE !== "downloadable") 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 Promise.all([loadBundledManifest(), registerBrowserContentWorker()]); if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates(); })(); } return initialization; } async function sha256Bytes(bytes: ArrayBuffer): Promise { 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, ): Promise { const storage = browserCacheStorage(); if (!storage) throw new Error("Offline content storage is unavailable in this browser."); const totalBytes = changes.reduce((total, [, entry]) => total + 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 [logicalPath, entry] of changes) { const response = await fetch(absoluteContentUrl(entry.url), { cache: "no-store" }); if (!response.ok) throw new Error(`Content request failed (${response.status}): ${logicalPath}`); const bytes = await response.arrayBuffer(); if (bytes.byteLength !== entry.bytes) { throw new Error(`Downloaded content has the wrong size: ${logicalPath}`); } if (await sha256Bytes(bytes) !== entry.sha256) { throw new Error(`Downloaded content failed verification: ${logicalPath}`); } const headers = new Headers(); const contentType = response.headers.get("content-type"); if (contentType) headers.set("content-type", contentType); await staging.put(browserCacheRequest(logicalPath), new Response(bytes, { headers, status: 200, })); completedBytes += entry.bytes; publish({ downloadedBytes: completedBytes }); } const active = await storage.open(BROWSER_CONTENT_CACHE_NAME); for (const [logicalPath] of changes) { const cached = await staging.match(browserCacheRequest(logicalPath)); if (!cached) throw new Error(`Verified content was not staged: ${logicalPath}`); await active.put(browserCacheRequest(logicalPath), cached); } const pendingFiles = { ...installedState.files }; 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]; } for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) { pendingFiles[logicalPath] = { packId, sha256: entry.sha256, bytes: entry.bytes, extension: extensionForEntry(entry), fileUri: absoluteContentUrl(logicalPath), }; } installedState = { version: 1, files: pendingFiles, packVersions: { ...installedState.packVersions, [packId]: manifest.version }, }; 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 storage.delete(stagingName).catch(() => false); } } 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 (!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 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: "", installedRevision: snapshot.installedRevision + 1, }); } 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 (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 packVersions = { ...installedState.packVersions }; delete packVersions[packId]; installedState = { version: 1, files: remainingFiles, packVersions }; persistInstalledState(); publish({ error: "", installedRevision: snapshot.installedRevision + 1 }); }