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>; } interface PersistedContentState { readonly version: 2; readonly packs: 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: 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 | null = null; let installedStorageValidation: Promise | null = null; let installedStorageValidationComplete = Object.keys(installedState.packs).length === 0; const validatedInstalledFiles = new Set(); const usablePackCache = new Map(); function safeLocalStorage(): Storage | null { try { return typeof window === "undefined" ? null : window.localStorage; } catch { return null; } } function isRecord(value: unknown): value is Record { 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 = {}; 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 = {}; 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 = {}; 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>): 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 { 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 { if (!installedStorageValidation) { installedStorageValidation = (async () => { const validFiles: Record = {}; 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 { 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> { 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> { 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>, ): 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): 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 { 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: 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 { 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 { 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 active = await storage.open(BROWSER_CONTENT_CACHE_NAME); const objects = new Map(); 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 { 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 { 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 { 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 }); }