Release Healer Man 0.1.14

This commit is contained in:
phenom committed 2026-09-05 11:40:50 -04:00
1 parent 67d7a8ebe7
commit 7b41b102fa
147 files changed
+169892 -1342

No files matched your search

+194 -24
View File
@@ -11,7 +11,23 @@ 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"]);
const STATIC_ANDROID_BUNDLED_PACKS = new Set([
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"dungeon:wailing-caverns",
]);
const STATIC_BROWSER_BUNDLED_PACKS = new Set([
"core:ui",
"core:equipment",
"core:character-basic",
"core:runtime",
"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;
@@ -29,6 +45,7 @@ interface PersistedContentState {
export interface ContentManagerSnapshot {
readonly revision: number;
readonly installedRevision: number;
readonly checking: boolean;
readonly manifest: ContentManifest | null;
readonly bundledManifest: ContentManifest | null;
@@ -42,6 +59,7 @@ const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files
let installedState = readInstalledState();
let snapshot: ContentManagerSnapshot = {
revision: 0,
installedRevision: 0,
checking: false,
manifest: null,
bundledManifest: null,
@@ -87,13 +105,45 @@ 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<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",
});
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<Record<string, { readonly sha256: string }>> {
return snapshot.bundledManifest?.files ?? {};
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<Record<string, { readonly sha256: string }>> {
@@ -117,21 +167,31 @@ export function resolveContentUrl(logicalUrl: string): string {
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 (!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;
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) return false;
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(pack?.dependencies.some((dependency) => contentPackHasUpdate(dependency)));
|| Boolean(!contentPackIsBundled(packId)
&& pack?.dependencies.some((dependency) => (
!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)
)));
}
export async function checkForContentUpdates(): Promise<ContentManifest | null> {
@@ -149,7 +209,7 @@ export async function checkForContentUpdates(): Promise<ContentManifest | null>
}
async function loadBundledManifest(): Promise<void> {
if (!Capacitor.isNativePlatform()) return;
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;
@@ -162,13 +222,102 @@ async function loadBundledManifest(): Promise<void> {
export function initializeContentSystem(): Promise<void> {
if (!initialization) {
initialization = (async () => {
await loadBundledManifest();
await Promise.all([loadBundledManifest(), registerBrowserContentWorker()]);
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 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<void> {
const manifest = snapshot.manifest ?? await checkForContentUpdates();
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
@@ -177,15 +326,20 @@ export async function installContentPack(packId: string): Promise<void> {
}
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);
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;
@@ -232,7 +386,13 @@ export async function installContentPack(packId: string): Promise<void> {
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
};
persistInstalledState();
publish({ installingPackId: null, downloadedBytes: totalBytes, totalBytes, error: "" });
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;
@@ -242,19 +402,29 @@ export async function installContentPack(packId: string): Promise<void> {
}
export async function removeContentPack(packId: string): Promise<void> {
if (!Capacitor.isNativePlatform()) return;
if (contentPackIsBundled(packId)) 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];
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: "" });
publish({ error: "", installedRevision: snapshot.installedRevision + 1 });
}