Release Healer Man 0.1.22

This commit is contained in:
phenom committed 2026-09-09 11:21:33 -04:00
1 parent f6f2375145
commit 7f6001723a
144 files changed
+54107 -690

No files matched your search

+235 -23
View File
@@ -9,7 +9,7 @@ import {
} from "./contentManifest";
import { NativeContentStorage } from "./nativeContentStorage";
const CONTENT_STATE_KEY = "healer-man.content-state.v1";
export const CONTENT_STATE_KEY = "healer-man.content-state.v2";
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
const STATIC_ANDROID_BUNDLED_PACKS = new Set([
"core:ui",
@@ -30,6 +30,8 @@ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
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";
export interface InstalledContentFile {
readonly packId: string;
@@ -39,9 +41,20 @@ export interface InstalledContentFile {
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: 1;
readonly packVersions: Readonly<Record<string, string>>;
readonly version: 2;
readonly packs: Readonly<Record<string, InstalledContentPackReceipt>>;
readonly files: Readonly<Record<string, InstalledContentFile>>;
}
@@ -57,7 +70,7 @@ export interface ContentManagerSnapshot {
readonly error: string;
}
const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files: {} };
const EMPTY_STATE: PersistedContentState = { version: 2, packs: {}, files: {} };
let installedState = readInstalledState();
let snapshot: ContentManagerSnapshot = {
revision: 0,
@@ -72,16 +85,69 @@ let snapshot: ContentManagerSnapshot = {
};
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>();
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 Partial<PersistedContentState> | null;
if (!value || value.version !== 1 || !value.files || !value.packVersions) return EMPTY_STATE;
return { version: 1, files: value.files, packVersions: value.packVersions };
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;
}
@@ -116,6 +182,60 @@ 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 active.match(browserCacheRequest(logicalPath));
if (cached?.headers.get(BROWSER_CONTENT_SHA256_HEADER) === entry.sha256
&& cached.headers.get(BROWSER_CONTENT_BYTES_HEADER) === String(entry.bytes)) {
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
@@ -137,6 +257,19 @@ function extensionForEntry(entry: ContentManifestFile): string {
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 {};
@@ -166,7 +299,9 @@ export function resolveContentUrl(logicalUrl: string): string {
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;
return installed?.fileUri && validatedInstalledFiles.has(pathname)
? Capacitor.convertFileSrc(installed.fileUri)
: logicalUrl;
}
export function contentPackIsBundled(packId: string): boolean {
@@ -176,20 +311,66 @@ export function contentPackIsBundled(packId: string): boolean {
}
export function contentPackIsInstalled(packId: string): boolean {
return contentPackIsBundled(packId) || Boolean(installedState.packVersions[packId]);
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];
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 {
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;
return contentPackIsUsableInternal(packId, new Set());
}
/** 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(`dungeon:${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 changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
return Boolean(!contentPackIsBundled(packId) && !contentPackIsUsable(packId))
|| changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|| Boolean(!contentPackIsBundled(packId)
&& pack?.dependencies.some((dependency) => (
!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)
@@ -224,7 +405,11 @@ async function loadBundledManifest(): Promise<void> {
export function initializeContentSystem(): Promise<void> {
if (!initialization) {
initialization = (async () => {
await Promise.all([loadBundledManifest(), registerBrowserContentWorker()]);
await Promise.all([
loadBundledManifest(),
registerBrowserContentWorker(),
validateInstalledContentStorage(),
]);
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
})();
}
@@ -268,6 +453,8 @@ async function installBrowserContentPack(
const headers = new Headers();
const contentType = response.headers.get("content-type");
if (contentType) headers.set("content-type", contentType);
headers.set(BROWSER_CONTENT_SHA256_HEADER, entry.sha256);
headers.set(BROWSER_CONTENT_BYTES_HEADER, String(entry.bytes));
await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
headers,
status: 200,
@@ -289,6 +476,7 @@ async function installBrowserContentPack(
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)) {
pendingFiles[logicalPath] = {
@@ -298,11 +486,15 @@ async function installBrowserContentPack(
extension: extensionForEntry(entry),
fileUri: absoluteContentUrl(logicalPath),
};
validatedInstalledFiles.add(logicalPath);
}
installedState = {
version: 1,
version: 2,
files: pendingFiles,
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
packs: {
...installedState.packs,
[packId]: packReceiptForManifest(manifest, packId),
},
};
persistInstalledState();
publish({
@@ -321,6 +513,7 @@ async function installBrowserContentPack(
}
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)) {
@@ -380,12 +573,30 @@ export async function installContentPack(packId: string): Promise<void> {
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];
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) 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: 1,
version: 2,
files: pendingFiles,
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
packs: {
...installedState.packs,
[packId]: packReceiptForManifest(manifest, packId),
},
};
persistInstalledState();
publish({
@@ -424,9 +635,10 @@ export async function removeContentPack(packId: string): Promise<void> {
delete remainingFiles[logicalPath];
}
}
const packVersions = { ...installedState.packVersions };
delete packVersions[packId];
installedState = { version: 1, files: remainingFiles, packVersions };
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 });
}