Release Healer Man 0.1.29
This commit is contained in:
1 parent
8c9809d3e0
commit
544c1bee91
120 files changed
+4363
-1036
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ARENA_STAGES } from "../game/arenaStages";
|
||||
@@ -9,10 +10,11 @@ const workerSource = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function contentWorker(cached?: Response) {
|
||||
function contentWorker(cached?: Response, bundledFiles: Record<string, unknown> = {}, object?: Response) {
|
||||
const handlers = new Map<string, (event: unknown) => void>();
|
||||
const fetch = vi.fn(async () => new Response("bundled arena", { status: 200 }));
|
||||
const match = vi.fn(async () => cached);
|
||||
const fetch = vi.fn(async (url: RequestInfo | URL) => String(url) === "/content/bootstrap-manifest.json"
|
||||
? Response.json({ files: bundledFiles }) : object?.clone() ?? new Response("bundled arena", { status: 200 }));
|
||||
const match = vi.fn(async (url: RequestInfo | URL) => String(url).includes("/content/cache-objects/") ? object?.clone() : cached?.clone());
|
||||
runInNewContext(workerSource, {
|
||||
self: {
|
||||
location: { origin: "https://healer.test" },
|
||||
@@ -22,10 +24,13 @@ function contentWorker(cached?: Response) {
|
||||
fetch,
|
||||
URL,
|
||||
Response,
|
||||
Headers,
|
||||
DecompressionStream,
|
||||
});
|
||||
return {
|
||||
fetch,
|
||||
match,
|
||||
refresh: () => handlers.get("message")!({ data: { type: "refresh-content-bootstrap" } }),
|
||||
request: (path: string) => {
|
||||
let response: Promise<Response> | undefined;
|
||||
handlers.get("fetch")!({
|
||||
@@ -45,7 +50,7 @@ describe("browser content cache worker", () => {
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.text()).toBe("bundled arena");
|
||||
}
|
||||
expect(worker.fetch).toHaveBeenCalledTimes(stage.assets.length);
|
||||
expect(worker.fetch).toHaveBeenCalledTimes(stage.assets.length + 1);
|
||||
});
|
||||
|
||||
it("prefers installed arena updates over bundled files, ignoring the runtime query", async () => {
|
||||
@@ -61,7 +66,8 @@ describe("browser content cache worker", () => {
|
||||
const response = await worker.request("/assets/game/manastorm/optional/visual.glb");
|
||||
expect(response?.status).toBe(404);
|
||||
expect(await response?.text()).toBe("Optional content is not installed.");
|
||||
expect(worker.fetch).not.toHaveBeenCalled();
|
||||
expect(worker.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(worker.fetch).toHaveBeenCalledWith("/content/bootstrap-manifest.json", { cache: "no-cache" });
|
||||
});
|
||||
|
||||
it("serves only cached same-origin asset requests from the active content cache", () => {
|
||||
@@ -70,11 +76,38 @@ describe("browser content cache worker", () => {
|
||||
expect(workerSource).toContain("url.origin !== self.location.origin");
|
||||
expect(workerSource).toContain("MANAGED_ASSET_PREFIXES.some");
|
||||
expect(workerSource).toContain('"/assets/game/"');
|
||||
expect(workerSource).toContain("if (cached) return cached");
|
||||
expect(workerSource).toContain("if (!objectPath) return cached");
|
||||
expect(workerSource).toContain("BUNDLED_ASSET_PATHS.has(url.pathname)");
|
||||
expect(workerSource).toContain("BUNDLED_ASSET_PREFIXES.some");
|
||||
expect(workerSource).not.toContain('"/assets/characters/wow335a-v1/textures/character/human/",');
|
||||
expect(workerSource).toContain('status: 404');
|
||||
expect(workerSource).not.toContain("cached ?? fetch(request)");
|
||||
});
|
||||
|
||||
it("expands bundled gzip without requiring a Manastorm install", async () => {
|
||||
const url = "/assets/game/manastorm/shared/visual.glb";
|
||||
const original = "glTF original dungeon".repeat(100);
|
||||
const worker = contentWorker(undefined, { [url]: { download: { url: "/content/bundled/object.glb.gz", encoding: "gzip" } } }, new Response(gzipSync(original)));
|
||||
const response = await worker.request(url);
|
||||
expect(await response?.text()).toBe(original);
|
||||
expect(response?.headers.get("content-type")).toBe("model/gltf-binary");
|
||||
});
|
||||
|
||||
it("resolves a tiny installed alias to one compressed shared object", async () => {
|
||||
const original = "glTF installed original".repeat(100);
|
||||
const worker = contentWorker(new Response(null, { headers: { "x-healer-man-content-object": "/content/cache-objects/shared.glb" } }), {},
|
||||
new Response(gzipSync(original), { headers: { "x-healer-man-content-encoding": "gzip" } }));
|
||||
expect(await (await worker.request("/assets/creatures/optional/model.glb"))?.text()).toBe(original);
|
||||
expect(worker.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes bootstrap mappings when a new app session starts", async () => {
|
||||
const path = "/assets/game/dungeons/new/visual.glb";
|
||||
const files: Record<string, unknown> = {};
|
||||
const worker = contentWorker(undefined, files, new Response(gzipSync("new original")));
|
||||
expect((await worker.request(path))?.status).toBe(404);
|
||||
files[path] = { download: { url: "/content/bundled/new.glb.gzip", encoding: "gzip" } };
|
||||
worker.refresh();
|
||||
expect(await (await worker.request(path))?.text()).toBe("new original");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { gzipSync, gunzipSync } from "node:zlib";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BROWSER_CONTENT_CACHE_NAME,
|
||||
browserContentObjectPath,
|
||||
checkForContentUpdates,
|
||||
contentPackHasUpdate,
|
||||
contentPackIsBundled,
|
||||
@@ -120,7 +122,8 @@ describe("browser content packs", () => {
|
||||
error: "",
|
||||
});
|
||||
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME);
|
||||
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
|
||||
expect(await (await active?.match(browserContentObjectPath(manifest.files["/assets/characters/test.glb"])))?.text()).toBe("first-model");
|
||||
const originalObject = browserContentObjectPath(manifest.files["/assets/characters/test.glb"]);
|
||||
|
||||
manifest = contentManifest("content-two", "updated.glb", updatedBytes);
|
||||
await checkForContentUpdates();
|
||||
@@ -129,12 +132,13 @@ describe("browser content packs", () => {
|
||||
|
||||
corruptUpdate = true;
|
||||
await expect(installContentPack("characters:test")).rejects.toThrow(/verification/i);
|
||||
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
|
||||
expect(await (await active?.match(originalObject))?.text()).toBe("first-model");
|
||||
|
||||
corruptUpdate = false;
|
||||
await installContentPack("characters:test");
|
||||
expect(contentPackHasUpdate("characters:test")).toBe(false);
|
||||
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("updated-model");
|
||||
expect(await (await active?.match(browserContentObjectPath(manifest.files["/assets/characters/test.glb"])))?.text()).toBe("updated-model");
|
||||
expect(await active?.match(originalObject)).toBeUndefined();
|
||||
|
||||
await removeContentPack("characters:test");
|
||||
expect(contentPackIsInstalled("characters:test")).toBe(false);
|
||||
@@ -151,6 +155,63 @@ describe("browser content packs", () => {
|
||||
expect(contentPackIsUsable("core:ui")).toBe(true);
|
||||
});
|
||||
|
||||
it("stores a compressed duplicate once and retains it until its last pack is removed", async () => {
|
||||
const original = new TextEncoder().encode("original shared geometry ".repeat(200));
|
||||
const compressed = gzipSync(original);
|
||||
const firstPath = "/assets/characters/shared-first.glb";
|
||||
const secondPath = "/assets/characters/shared-second.glb";
|
||||
const thirdPath = "/assets/characters/shared-third.glb";
|
||||
const base = contentManifest("compressed", "original.glb", original);
|
||||
const entry = { ...base.files["/assets/characters/test.glb"], download: {
|
||||
url: "/content/objects/shared.glb.gz", encoding: "gzip" as const, sha256: sha256(compressed), bytes: compressed.length,
|
||||
} };
|
||||
const manifest: ContentManifest = { ...base, packs: [
|
||||
{ ...base.packs[0], id: "characters:shared-first", fileCount: 2 },
|
||||
{ ...base.packs[0], id: "characters:shared-second" },
|
||||
], files: {
|
||||
[firstPath]: { ...entry, packId: "characters:shared-first" },
|
||||
[secondPath]: { ...entry, packId: "characters:shared-first" },
|
||||
[thirdPath]: { ...entry, packId: "characters:shared-second" },
|
||||
} };
|
||||
const storage = new MemoryCacheStorage();
|
||||
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||
vi.stubGlobal("caches", storage as unknown as CacheStorage);
|
||||
const fetcher = vi.fn(async (url: RequestInfo | URL) => String(url).endsWith("manifest.json") ? Response.json(manifest) : new Response(compressed));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
await checkForContentUpdates();
|
||||
await installContentPack("characters:shared-first");
|
||||
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME)!;
|
||||
expect(active.responses.size).toBe(3);
|
||||
const objectPath = browserContentObjectPath(entry);
|
||||
expect(gunzipSync(Buffer.from(await (await active.match(objectPath))!.arrayBuffer()))).toEqual(Buffer.from(original));
|
||||
expect(getContentManagerSnapshot().totalBytes).toBe(compressed.length);
|
||||
await installContentPack("characters:shared-second");
|
||||
expect(getContentManagerSnapshot().totalBytes).toBe(0);
|
||||
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith(".gz"))).toHaveLength(1);
|
||||
await removeContentPack("characters:shared-first");
|
||||
expect(await active.match(objectPath)).toBeDefined();
|
||||
expect(contentPackIsUsable("characters:shared-second")).toBe(true);
|
||||
await removeContentPack("characters:shared-second");
|
||||
expect(active.responses.size).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a valid gzip payload that expands to the wrong original model", async () => {
|
||||
const original = new TextEncoder().encode("expected model");
|
||||
const compressed = gzipSync("different model");
|
||||
const base = contentManifest("bad-compressed", "model.glb", original);
|
||||
const manifest = { ...base, files: { "/assets/characters/test.glb": { ...base.files["/assets/characters/test.glb"],
|
||||
download: { url: "/content/objects/model.glb.gz", encoding: "gzip", sha256: sha256(compressed), bytes: compressed.length },
|
||||
} } };
|
||||
const storage = new MemoryCacheStorage();
|
||||
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||
vi.stubGlobal("caches", storage as unknown as CacheStorage);
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: RequestInfo | URL) => String(url).endsWith("manifest.json") ? Response.json(manifest) : new Response(compressed)));
|
||||
await checkForContentUpdates();
|
||||
await expect(installContentPack("characters:test")).rejects.toThrow(/expanded content failed verification/i);
|
||||
expect(storage.stores.get(BROWSER_CONTENT_CACHE_NAME)?.responses.size).toBe(0);
|
||||
expect(contentPackIsUsable("characters:test")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses source-served dungeons in development and the content gate in shipped builds", () => {
|
||||
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
|
||||
vi.stubEnv("DEV", true);
|
||||
|
||||
+104
-61
@@ -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 };
|
||||
|
||||
@@ -130,6 +130,25 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("persisted content readiness", () => {
|
||||
it.each([false, true])("honors explicit bundle flags without installing shared Manastorm maps (native=%s)", async (native) => {
|
||||
const { manager } = await loadOfflineManager({ version: 2, files: {}, packs: {} }, []);
|
||||
vi.stubEnv("VITE_CONTENT_MODE", "downloadable");
|
||||
const { Capacitor } = await import("@capacitor/core");
|
||||
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(native);
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json({
|
||||
schemaVersion: 1, version: "bundled", createdAt: "2026-09-10", minimumAppVersion: "0.1.0",
|
||||
packs: [
|
||||
{ id: "dungeon:test", label: "Test", bytes: 0, fileCount: 0, bundledOnAndroid: true, dependencies: ["manastorm:test"] },
|
||||
{ id: "manastorm:test", label: "Map", bytes: 11, fileCount: 1, bundledOnAndroid: false, dependencies: [] },
|
||||
],
|
||||
files: { [ENVIRONMENT_PATH]: { packId: "manastorm:test", url: "/content/bundled/model.glb", sha256: ENVIRONMENT_HASH, bytes: 11 } },
|
||||
})));
|
||||
await manager.initializeContentSystem();
|
||||
expect(manager.dungeonContentIsUsable("test")).toBe(true);
|
||||
expect(manager.contentPackIsBundled("manastorm:test")).toBe(false);
|
||||
expect(manager.contentPackIsInstalled("manastorm:test")).toBe(false);
|
||||
});
|
||||
|
||||
it("restores a complete dependency-only dungeon closure offline after verifying storage", async () => {
|
||||
const { manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH, CREATURE_PATH]);
|
||||
|
||||
|
||||
@@ -31,6 +31,15 @@ describe("content manifest", () => {
|
||||
expect(appVersionMeetsMinimum("1.1.9", "1.2.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsupported or unverified compressed representations", () => {
|
||||
const withDownload = (download: unknown) => ({ ...manifest, files: { "/assets/a.glb": { ...manifest.files["/assets/a.glb"], download } } });
|
||||
const download = { url: "/content/objects/a.glb.gz", encoding: "gzip", sha256: "c".repeat(64), bytes: 10 };
|
||||
expect(() => parseContentManifest(withDownload(download))).not.toThrow();
|
||||
expect(() => parseContentManifest(withDownload({ ...download, encoding: "unknown" }))).toThrow(/compressed file/i);
|
||||
expect(() => parseContentManifest(withDownload({ ...download, sha256: "invalid" }))).toThrow(/compressed file/i);
|
||||
expect(() => parseContentManifest(withDownload({ ...download, bytes: -1 }))).toThrow(/compressed file/i);
|
||||
});
|
||||
|
||||
it("downloads only files whose verified hash changed", () => {
|
||||
expect(filesForContentPack(manifest, "dungeon:test")).toHaveLength(2);
|
||||
expect(changedFilesForPack(manifest, "dungeon:test", {
|
||||
|
||||
@@ -5,12 +5,19 @@ export interface ContentManifestFile {
|
||||
readonly url: string;
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
readonly download?: {
|
||||
readonly url: string;
|
||||
readonly encoding: "gzip";
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentManifestPack {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly bytes: number;
|
||||
readonly downloadBytes?: number;
|
||||
readonly fileCount: number;
|
||||
readonly bundledOnAndroid: boolean;
|
||||
readonly dependencies: readonly string[];
|
||||
@@ -63,6 +70,14 @@ export function parseContentManifest(value: unknown): ContentManifest {
|
||||
|| entry.bytes < 0) {
|
||||
throw new Error(`The content catalog contains a malformed file: ${logicalPath}`);
|
||||
}
|
||||
if (entry.download !== undefined && (!isRecord(entry.download)
|
||||
|| entry.download.encoding !== "gzip"
|
||||
|| typeof entry.download.url !== "string"
|
||||
|| !/^[a-f0-9]{64}$/.test(String(entry.download.sha256))
|
||||
|| !Number.isSafeInteger(entry.download.bytes)
|
||||
|| Number(entry.download.bytes) < 0)) {
|
||||
throw new Error(`The content catalog contains a malformed compressed file: ${logicalPath}`);
|
||||
}
|
||||
return [logicalPath, entry as unknown as ContentManifestFile];
|
||||
}));
|
||||
return { ...value, packs, files } as unknown as ContentManifest;
|
||||
|
||||
@@ -18,6 +18,9 @@ interface DownloadOptions {
|
||||
readonly sha256: string;
|
||||
readonly extension: string;
|
||||
readonly bytes: number;
|
||||
readonly encoding?: "gzip";
|
||||
readonly downloadSha256?: string;
|
||||
readonly downloadBytes?: number;
|
||||
}
|
||||
|
||||
interface ObjectOptions {
|
||||
|
||||
Reference in new issue
Block a user