Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+104 -61
View File
@@ -32,6 +32,8 @@ 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;
@@ -178,6 +180,23 @@ function browserCacheRequest(logicalPath: string): Request {
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;
}
@@ -209,9 +228,8 @@ async function validateInstalledContentStorage(): Promise<void> {
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)) {
const cached = await cachedLogicalObject(active, logicalPath);
if (verifiedCachedResponse(cached, entry)) {
validFiles[logicalPath] = entry;
}
}
@@ -246,7 +264,8 @@ async function registerBrowserContentWorker(): Promise<void> {
scope: "/",
updateViaCache: "none",
});
await navigator.serviceWorker.ready;
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);
}
@@ -305,9 +324,9 @@ export function resolveContentUrl(logicalUrl: string): string {
}
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));
const defaults = Capacitor.isNativePlatform() ? STATIC_ANDROID_BUNDLED_PACKS : STATIC_BROWSER_BUNDLED_PACKS;
return defaults.has(packId)
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId && pack.bundledOnAndroid));
}
export function contentPackIsInstalled(packId: string): boolean {
@@ -323,6 +342,8 @@ function matchingReceiptFiles(
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
@@ -394,7 +415,7 @@ export async function checkForContentUpdates(): Promise<ContentManifest | 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: "force-cache" });
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 {
@@ -431,46 +452,63 @@ async function installBrowserContentPack(
): 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 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 [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}`);
for (const [objectPath, { logicalPath, entry, cached }] of objects) {
if (cached) {
await staging.put(browserCacheRequest(objectPath), cached);
continue;
}
if (await sha256Bytes(bytes) !== entry.sha256) {
throw new Error(`Downloaded content failed verification: ${logicalPath}`);
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();
const contentType = response.headers.get("content-type");
if (contentType) headers.set("content-type", contentType);
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));
await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
headers,
status: 200,
}));
completedBytes += 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 });
}
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);
// 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);
}
const pendingFiles = { ...installedState.files };
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;
@@ -479,31 +517,21 @@ async function installBrowserContentPack(
validatedInstalledFiles.delete(logicalPath);
}
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
pendingFiles[logicalPath] = {
packId,
sha256: entry.sha256,
bytes: entry.bytes,
extension: extensionForEntry(entry),
fileUri: absoluteContentUrl(logicalPath),
};
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),
},
};
installedState = { version: 2, files: pendingFiles, packs: {
...installedState.packs, [packId]: packReceiptForManifest(manifest, packId),
} };
persistInstalledState();
publish({
installingPackId: null,
downloadedBytes: totalBytes,
totalBytes,
error: "",
installedRevision: snapshot.installedRevision + 1,
});
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;
@@ -535,11 +563,17 @@ export async function installContentPack(packId: string): Promise<void> {
await installBrowserContentPack(packId, manifest, changes);
return;
}
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
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 < totalBytes + safetyReserve) {
const error = `Not enough free storage. ${Math.ceil((totalBytes + safetyReserve - storage.freeBytes) / 1024 / 1024)} MB more is required.`;
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);
}
@@ -554,11 +588,12 @@ export async function installContentPack(packId: string): Promise<void> {
for (const [logicalPath, entry] of changes) {
const extension = extensionForEntry(entry);
const result = await NativeContentStorage.download({
url: absoluteContentUrl(entry.url),
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,
@@ -567,7 +602,9 @@ export async function installContentPack(packId: string): Promise<void> {
extension,
fileUri: result.fileUri,
};
completedBytes += entry.bytes;
const objectKey = browserContentObjectPath(entry);
if (!existingObjects.has(objectKey)) completedBytes += entry.download?.bytes ?? entry.bytes;
existingObjects.add(objectKey);
publish({ downloadedBytes: completedBytes });
}
@@ -580,6 +617,7 @@ export async function installContentPack(packId: string): Promise<void> {
}
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,
@@ -634,6 +672,11 @@ export async function removeContentPack(packId: string): Promise<void> {
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 };