Extract catalog remote loading seam
This commit is contained in:
parent
161ec56bf0
commit
8ff7ae1625
6 changed files with 264 additions and 197 deletions
|
|
@ -67,6 +67,7 @@ const behaviorTests = [
|
|||
".test-dist/tests/zip-safety.test.js",
|
||||
".test-dist/tests/tts-engine.test.js",
|
||||
".test-dist/tests/codex-familiars.test.js",
|
||||
".test-dist/tests/catalog-remote-seams.test.js",
|
||||
".test-dist/tests/claude-memory.test.js",
|
||||
".test-dist/tests/prompt-memory-extraction.test.js",
|
||||
".test-dist/tests/familiaros-memory-search.test.js",
|
||||
|
|
|
|||
195
apps/desktop/src/catalog-remote.ts
Normal file
195
apps/desktop/src/catalog-remote.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { app } from "electron";
|
||||
|
||||
import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, validateCatalogV3SearchIndex, validateCatalogV3SearchPage, type CatalogPetV2, type CatalogV2, type CatalogV3Index, type CatalogV3SearchPet } from "./catalog-validation.js";
|
||||
|
||||
export const catalogUrl = "https://familiaros.dev/familiars/catalog.v2.json";
|
||||
export const catalogV3Url = "https://familiaros.dev/familiars/catalog.v3.json";
|
||||
|
||||
type CatalogLoadResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: string };
|
||||
|
||||
const fixtureRelativePath = "catalog.v2.fixture.json";
|
||||
const maxCatalogBytes = 1_000_000;
|
||||
const maxCatalogV3PageBytes = 256_000;
|
||||
const fetchTimeoutMs = 5_000;
|
||||
|
||||
const v3PageCache = new Map<number, readonly CatalogPetV2[]>();
|
||||
let v3IndexPromise: Promise<CatalogV3Index> | null = null;
|
||||
let v3SearchPromise: Promise<readonly CatalogV3SearchPet[]> | null = null;
|
||||
let v2CatalogPromise: Promise<CatalogV2> | null = null;
|
||||
|
||||
export async function tryLoadRemoteCatalogV3Index(): Promise<CatalogLoadResult<CatalogV3Index>> {
|
||||
try {
|
||||
return { ok: true, value: await getRemoteCatalogV3Index() };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<CatalogLoadResult<readonly CatalogPetV2[]>> {
|
||||
try {
|
||||
return { ok: true, value: await getRemoteCatalogV3Page(page, index) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryLoadRemoteCatalog(): Promise<CatalogLoadResult<CatalogV2>> {
|
||||
try {
|
||||
return { ok: true, value: await getRemoteCatalogV2() };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryLoadFixtureCatalog(): Promise<CatalogLoadResult<CatalogV2>> {
|
||||
try {
|
||||
return { ok: true, value: validateCatalogV2(await loadFixtureCatalog()) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRemoteCatalogV3Index(): Promise<CatalogV3Index> {
|
||||
v3IndexPromise ||= Promise.resolve().then(async () => validateCatalogV3Index(JSON.parse(await fetchLimitedText(catalogV3Url, maxCatalogV3PageBytes)) as unknown));
|
||||
return await v3IndexPromise;
|
||||
}
|
||||
|
||||
export async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<readonly CatalogPetV2[]> {
|
||||
const cached = v3PageCache.get(page);
|
||||
if (cached) return cached;
|
||||
const pageUrl = index.pages[page];
|
||||
if (!pageUrl) throw new Error("Catalog page is out of range.");
|
||||
const payload = validateCatalogV3Page(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page);
|
||||
const familiars = payload.familiars.map(toCatalogPetV2Compat);
|
||||
assertUniquePetIds(familiars);
|
||||
v3PageCache.set(page, familiars);
|
||||
return familiars;
|
||||
}
|
||||
|
||||
export async function getRemoteCatalogV3Search(index: CatalogV3Index): Promise<readonly CatalogV3SearchPet[]> {
|
||||
v3SearchPromise ||= Promise.resolve().then(async () => {
|
||||
const searchIndex = validateCatalogV3SearchIndex(JSON.parse(await fetchLimitedText(index.search, maxCatalogV3PageBytes)) as unknown);
|
||||
const pages = await Promise.all(
|
||||
searchIndex.pages.map(async (pageUrl, page) =>
|
||||
validateCatalogV3SearchPage(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page, index.pages.length),
|
||||
),
|
||||
);
|
||||
const familiars = pages.flatMap((page) => page.familiars);
|
||||
if (familiars.length !== index.total) throw new Error("Catalog v3 search total does not match index total.");
|
||||
return familiars;
|
||||
});
|
||||
return await v3SearchPromise;
|
||||
}
|
||||
|
||||
export async function getV2CatalogOrFixture(): Promise<CatalogV2> {
|
||||
const remote = await tryLoadRemoteCatalog();
|
||||
if (remote.ok) return remote.value;
|
||||
const fixture = await tryLoadFixtureCatalog();
|
||||
if (fixture.ok) return fixture.value;
|
||||
throw new Error(`Catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`);
|
||||
}
|
||||
|
||||
function toCatalogPetV2Compat(familiar: {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly description: string;
|
||||
readonly thumbnail: string;
|
||||
readonly spritesheet: string;
|
||||
readonly zip: string;
|
||||
readonly category: "western" | "asian";
|
||||
readonly subcategory?: string;
|
||||
readonly original?: boolean;
|
||||
readonly featured?: boolean;
|
||||
}): CatalogPetV2 {
|
||||
const entry: CatalogPetV2 = {
|
||||
id: familiar.id,
|
||||
displayName: familiar.displayName,
|
||||
description: familiar.description,
|
||||
preview: familiar.thumbnail,
|
||||
spritesheet: familiar.spritesheet,
|
||||
zip: familiar.zip,
|
||||
category: familiar.category,
|
||||
};
|
||||
return {
|
||||
...entry,
|
||||
...(familiar.subcategory ? { subcategory: familiar.subcategory } : {}),
|
||||
...(familiar.original === undefined ? {} : { original: familiar.original }),
|
||||
...(familiar.featured === undefined ? {} : { featured: familiar.featured }),
|
||||
};
|
||||
}
|
||||
|
||||
async function getRemoteCatalogV2(): Promise<CatalogV2> {
|
||||
v2CatalogPromise ||= Promise.resolve().then(async () => validateCatalogV2(JSON.parse(await fetchLimitedText(catalogUrl, maxCatalogBytes)) as unknown));
|
||||
return await v2CatalogPromise;
|
||||
}
|
||||
|
||||
async function fetchLimitedText(url: string, maxBytes: number): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
validateCatalogEndpoint(response.url, url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
return await readLimitedResponse(response, maxBytes);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFixtureCatalog(): Promise<unknown> {
|
||||
const fixturePath = join(app.getAppPath(), fixtureRelativePath);
|
||||
return JSON.parse(await readFile(fixturePath, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
async function readLimitedResponse(response: Response, maxBytes: number): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog response body is unavailable for bounded reading.");
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) throw new Error("Catalog response is too large.");
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(concatChunks(chunks, total));
|
||||
}
|
||||
|
||||
function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array {
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function validateCatalogEndpoint(value: string, expected: string): void {
|
||||
const url = new URL(value);
|
||||
if (url.href !== expected) throw new Error("Catalog final URL is not allowed.");
|
||||
}
|
||||
|
||||
function assertUniquePetIds(familiars: readonly CatalogPetV2[]): void {
|
||||
const ids = new Set<string>();
|
||||
for (const familiar of familiars) {
|
||||
if (ids.has(familiar.id)) throw new Error(`Duplicate catalog v3 familiar id: ${familiar.id}`);
|
||||
ids.add(familiar.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,8 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { app } from "electron";
|
||||
|
||||
import { filterSurfaceablePets, isSurfaceablePet, summarizeSurfaceablePets } from "./catalog-surfaceable.js";
|
||||
import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, validateCatalogV3SearchIndex, validateCatalogV3SearchPage, type CatalogPetV2, type CatalogV2, type CatalogV3Index, type CatalogV3SearchPet } from "./catalog-validation.js";
|
||||
import { getRemoteCatalogV3Page, getRemoteCatalogV3Search, getV2CatalogOrFixture, tryLoadFixtureCatalog, tryLoadRemoteCatalog, tryLoadRemoteCatalogV3Index } from "./catalog-remote.js";
|
||||
import type { CatalogPetV2, CatalogV3Index, CatalogV3SearchPet } from "./catalog-validation.js";
|
||||
|
||||
export const catalogUrl = "https://familiaros.dev/familiars/catalog.v2.json";
|
||||
export const catalogV3Url = "https://familiaros.dev/familiars/catalog.v3.json";
|
||||
const fixtureRelativePath = "catalog.v2.fixture.json";
|
||||
const maxCatalogBytes = 1_000_000;
|
||||
const maxCatalogV3PageBytes = 256_000;
|
||||
const fetchTimeoutMs = 5_000;
|
||||
export { catalogUrl, catalogV3Url } from "./catalog-remote.js";
|
||||
|
||||
export interface CatalogUiState {
|
||||
readonly source: "remote" | "fixture" | "error";
|
||||
|
|
@ -35,30 +26,25 @@ export interface CatalogSearchUiState {
|
|||
readonly error?: string;
|
||||
}
|
||||
|
||||
const v3PageCache = new Map<number, readonly CatalogPetV2[]>();
|
||||
let v3IndexPromise: Promise<CatalogV3Index> | null = null;
|
||||
let v3SearchPromise: Promise<readonly CatalogV3SearchPet[]> | null = null;
|
||||
let v2CatalogPromise: Promise<CatalogV2> | null = null;
|
||||
|
||||
export async function getCatalogUiState(): Promise<CatalogUiState> {
|
||||
const remoteV3 = await tryLoadRemoteCatalogV3Index();
|
||||
|
||||
if (remoteV3.ok) {
|
||||
const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.index);
|
||||
const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.value);
|
||||
if (!firstPage.ok) return await getV2OrFixtureCatalogUiState(`v3 page unavailable: ${firstPage.error}`);
|
||||
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index.pageSize);
|
||||
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value.pageSize);
|
||||
return {
|
||||
source: "remote",
|
||||
familiars: filterSurfaceablePets(firstPage.familiars),
|
||||
generatedAt: remoteV3.index.generatedAt,
|
||||
generatedAt: remoteV3.value.generatedAt,
|
||||
version: 3,
|
||||
total: surfaceableStats.total,
|
||||
categories: remoteV3.index.filters.categories,
|
||||
categories: remoteV3.value.filters.categories,
|
||||
page: 0,
|
||||
pageCount: surfaceableStats.pageCount,
|
||||
supportsCategories: true,
|
||||
originalsCount: remoteV3.index.filters.originalsCount,
|
||||
featuredCount: remoteV3.index.filters.featuredCount,
|
||||
originalsCount: remoteV3.value.filters.originalsCount,
|
||||
featuredCount: remoteV3.value.filters.featuredCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -69,23 +55,23 @@ export async function getCatalogPageUiState(page: number): Promise<CatalogUiStat
|
|||
if (!Number.isInteger(page) || page < 0) throw new Error("Catalog page must be a non-negative integer.");
|
||||
const remoteV3 = await tryLoadRemoteCatalogV3Index();
|
||||
if (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error };
|
||||
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index.pageSize);
|
||||
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value.pageSize);
|
||||
if (page >= surfaceableStats.pageCount) throw new Error("Catalog page is out of range.");
|
||||
const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.index);
|
||||
const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.value);
|
||||
if (!pageResult.ok) return { source: "error", familiars: [], error: pageResult.error };
|
||||
|
||||
return {
|
||||
source: "remote",
|
||||
familiars: filterSurfaceablePets(pageResult.familiars),
|
||||
generatedAt: remoteV3.index.generatedAt,
|
||||
generatedAt: remoteV3.value.generatedAt,
|
||||
version: 3,
|
||||
total: surfaceableStats.total,
|
||||
categories: remoteV3.index.filters.categories,
|
||||
categories: remoteV3.value.filters.categories,
|
||||
page,
|
||||
pageCount: surfaceableStats.pageCount,
|
||||
supportsCategories: true,
|
||||
originalsCount: remoteV3.index.filters.originalsCount,
|
||||
featuredCount: remoteV3.index.filters.featuredCount,
|
||||
originalsCount: remoteV3.value.filters.originalsCount,
|
||||
featuredCount: remoteV3.value.filters.featuredCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +80,7 @@ export async function getCatalogSearchUiState(): Promise<CatalogSearchUiState> {
|
|||
if (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error };
|
||||
|
||||
try {
|
||||
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index);
|
||||
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value);
|
||||
return { source: "remote", familiars: surfacedPets, total: surfacedPets.length };
|
||||
} catch (error) {
|
||||
return { source: "error", familiars: [], error: error instanceof Error ? error.message : "unknown error" };
|
||||
|
|
@ -106,14 +92,14 @@ export async function getCatalogPet(petId: string): Promise<CatalogPetV2> {
|
|||
if (remoteV3.ok) {
|
||||
let blockedHiddenV3Pet = false;
|
||||
try {
|
||||
const searchPets = await getRemoteCatalogV3Search(remoteV3.index);
|
||||
const searchPets = await getRemoteCatalogV3Search(remoteV3.value);
|
||||
const searchPet = searchPets.find((familiar) => familiar.id === petId);
|
||||
if (searchPet && !isSurfaceablePet(searchPet)) {
|
||||
blockedHiddenV3Pet = true;
|
||||
throw new Error(`Familiar is not available in the curated catalog: ${petId}`);
|
||||
}
|
||||
if (searchPet) {
|
||||
const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.index);
|
||||
const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.value);
|
||||
const familiar = page.find((candidate) => candidate.id === petId);
|
||||
if (familiar && isSurfaceablePet(familiar)) return familiar;
|
||||
}
|
||||
|
|
@ -135,10 +121,10 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
|
|||
if (remote.ok) {
|
||||
return {
|
||||
source: "remote",
|
||||
familiars: filterSurfaceablePets(remote.catalog.familiars),
|
||||
generatedAt: remote.catalog.generatedAt,
|
||||
familiars: filterSurfaceablePets(remote.value.familiars),
|
||||
generatedAt: remote.value.generatedAt,
|
||||
version: 2,
|
||||
total: filterSurfaceablePets(remote.catalog.familiars).length,
|
||||
total: filterSurfaceablePets(remote.value.familiars).length,
|
||||
supportsCategories: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -148,11 +134,11 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
|
|||
if (fixture.ok) {
|
||||
return {
|
||||
source: "fixture",
|
||||
familiars: filterSurfaceablePets(fixture.catalog.familiars),
|
||||
generatedAt: fixture.catalog.generatedAt,
|
||||
familiars: filterSurfaceablePets(fixture.value.familiars),
|
||||
generatedAt: fixture.value.generatedAt,
|
||||
error: `Catalog unavailable: ${remoteV3Error}; v2 unavailable: ${remote.error}`,
|
||||
version: 2,
|
||||
total: filterSurfaceablePets(fixture.catalog.familiars).length,
|
||||
total: filterSurfaceablePets(fixture.value.familiars).length,
|
||||
supportsCategories: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -171,23 +157,6 @@ function getSurfaceableSearchPets(familiars: readonly CatalogV3SearchPet[], inde
|
|||
}));
|
||||
}
|
||||
|
||||
async function tryLoadRemoteCatalogV3Index(): Promise<{ readonly ok: true; readonly index: CatalogV3Index } | { readonly ok: false; readonly error: string }> {
|
||||
try {
|
||||
const index = await getRemoteCatalogV3Index();
|
||||
return { ok: true, index };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly familiars: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
|
||||
try {
|
||||
return { ok: true, familiars: await getRemoteCatalogV3Page(page, index) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly familiars: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
|
||||
try {
|
||||
const searchPets = filterSurfaceablePets(await getRemoteCatalogV3Search(index));
|
||||
|
|
@ -201,144 +170,3 @@ async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Ind
|
|||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function getRemoteCatalogV3Index(): Promise<CatalogV3Index> {
|
||||
v3IndexPromise ||= Promise.resolve().then(async () => validateCatalogV3Index(JSON.parse(await fetchLimitedText(catalogV3Url, maxCatalogV3PageBytes)) as unknown));
|
||||
return await v3IndexPromise;
|
||||
}
|
||||
|
||||
async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<readonly CatalogPetV2[]> {
|
||||
const cached = v3PageCache.get(page);
|
||||
if (cached) return cached;
|
||||
const pageUrl = index.pages[page];
|
||||
if (!pageUrl) throw new Error("Catalog page is out of range.");
|
||||
const payload = validateCatalogV3Page(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page);
|
||||
const familiars = payload.familiars.map(toCatalogPetV2Compat);
|
||||
assertUniquePetIds(familiars);
|
||||
v3PageCache.set(page, familiars);
|
||||
return familiars;
|
||||
}
|
||||
|
||||
async function getRemoteCatalogV3Search(index: CatalogV3Index): Promise<readonly CatalogV3SearchPet[]> {
|
||||
v3SearchPromise ||= Promise.resolve().then(async () => {
|
||||
const searchIndex = validateCatalogV3SearchIndex(JSON.parse(await fetchLimitedText(index.search, maxCatalogV3PageBytes)) as unknown);
|
||||
const pages = await Promise.all(searchIndex.pages.map(async (pageUrl, page) => validateCatalogV3SearchPage(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page, index.pages.length)));
|
||||
const familiars = pages.flatMap((page) => page.familiars);
|
||||
if (familiars.length !== index.total) throw new Error("Catalog v3 search total does not match index total.");
|
||||
return familiars;
|
||||
});
|
||||
return await v3SearchPromise;
|
||||
}
|
||||
|
||||
function toCatalogPetV2Compat(familiar: { readonly id: string; readonly displayName: string; readonly description: string; readonly thumbnail: string; readonly spritesheet: string; readonly zip: string; readonly category: "western" | "asian"; readonly subcategory?: string; readonly original?: boolean; readonly featured?: boolean }): CatalogPetV2 {
|
||||
const entry: CatalogPetV2 = {
|
||||
id: familiar.id,
|
||||
displayName: familiar.displayName,
|
||||
description: familiar.description,
|
||||
preview: familiar.thumbnail,
|
||||
spritesheet: familiar.spritesheet,
|
||||
zip: familiar.zip,
|
||||
category: familiar.category,
|
||||
};
|
||||
return {
|
||||
...entry,
|
||||
...(familiar.subcategory ? { subcategory: familiar.subcategory } : {}),
|
||||
...(familiar.original === undefined ? {} : { original: familiar.original }),
|
||||
...(familiar.featured === undefined ? {} : { featured: familiar.featured }),
|
||||
};
|
||||
}
|
||||
|
||||
async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> {
|
||||
try {
|
||||
return { ok: true, catalog: await getRemoteCatalogV2() };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function getRemoteCatalogV2(): Promise<CatalogV2> {
|
||||
v2CatalogPromise ||= Promise.resolve().then(async () => validateCatalogV2(JSON.parse(await fetchLimitedText(catalogUrl, maxCatalogBytes)) as unknown));
|
||||
return await v2CatalogPromise;
|
||||
}
|
||||
|
||||
async function getV2CatalogOrFixture(): Promise<CatalogV2> {
|
||||
const remote = await tryLoadRemoteCatalog();
|
||||
if (remote.ok) return remote.catalog;
|
||||
const fixture = await tryLoadFixtureCatalog();
|
||||
if (fixture.ok) return fixture.catalog;
|
||||
throw new Error(`Catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`);
|
||||
}
|
||||
|
||||
async function fetchLimitedText(url: string, maxBytes: number): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: "error",
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
validateCatalogEndpoint(response.url, url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
return await readLimitedResponse(response, maxBytes);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLoadFixtureCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> {
|
||||
try {
|
||||
return { ok: true, catalog: validateCatalogV2(await loadFixtureCatalog()) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFixtureCatalog(): Promise<unknown> {
|
||||
const fixturePath = join(app.getAppPath(), fixtureRelativePath);
|
||||
return JSON.parse(await readFile(fixturePath, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
async function readLimitedResponse(response: Response, maxBytes: number): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog response body is unavailable for bounded reading.");
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) throw new Error("Catalog response is too large.");
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(concatChunks(chunks, total));
|
||||
}
|
||||
|
||||
function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array {
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function validateCatalogEndpoint(value: string, expected: string): void {
|
||||
const url = new URL(value);
|
||||
if (url.href !== expected) throw new Error("Catalog final URL is not allowed.");
|
||||
}
|
||||
|
||||
function assertUniquePetIds(familiars: readonly CatalogPetV2[]): void {
|
||||
const ids = new Set<string>();
|
||||
for (const familiar of familiars) {
|
||||
if (ids.has(familiar.id)) throw new Error(`Duplicate catalog v3 familiar id: ${familiar.id}`);
|
||||
ids.add(familiar.id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ const petWindowSource = readFileSync(join(appDir, "src", "familiar-window.ts"),
|
|||
const petWindowHostSource = readFileSync(join(appDir, "src", "familiar-window-host.ts"), "utf8");
|
||||
const familiarInstallationSource = readFileSync(join(appDir, "src", "familiar-installation.ts"), "utf8");
|
||||
const familiarInstallationArchiveSource = readFileSync(join(appDir, "src", "familiar-installation-archive.ts"), "utf8");
|
||||
const catalogSource = readFileSync(join(appDir, "src", "catalog.ts"), "utf8");
|
||||
const catalogRemoteSource = readFileSync(join(appDir, "src", "catalog-remote.ts"), "utf8");
|
||||
const petWindowContentSource = readFileSync(join(appDir, "src", "familiar-window-content.ts"), "utf8");
|
||||
const petWindowInteractionSource = readFileSync(join(appDir, "src", "familiar-window-interactions.ts"), "utf8");
|
||||
const petWindowMouseInteropSource = readFileSync(join(appDir, "src", "familiar-window-mouse-interop.ts"), "utf8");
|
||||
|
|
@ -353,6 +355,16 @@ assert.match(familiarInstallationArchiveSource, /redirect:\s*"error"/, "familiar
|
|||
assert.match(familiarInstallationArchiveSource, /validateZipUrl\(response\.url\)/, "familiar-installation archive downloads must re-validate the final URL.");
|
||||
assert.match(familiarInstallationArchiveSource, /strictFileNames:\s*true/, "familiar-installation archive extraction must keep yauzl strict file-name validation.");
|
||||
assert.match(familiarInstallationArchiveSource, /safePath\.relativeOutputPath === "spritesheet\.webp"[\s\S]*?maxCodexSpritesheetBytes/, "familiar-installation archive extraction must cap spritesheets at the codex size limit.");
|
||||
assert.match(catalogSource, /from "\.\/catalog-remote(?:\.js)?"/, "catalog must import the extracted remote helper seam.");
|
||||
assert.match(catalogSource, /export \{ catalogUrl, catalogV3Url \} from "\.\/catalog-remote(?:\.js)?"/, "catalog must re-export catalog URLs through the extracted remote seam.");
|
||||
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Index/, "catalog remote seam must export the v3 index loader.");
|
||||
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Page/, "catalog remote seam must export the v3 page loader.");
|
||||
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalog/, "catalog remote seam must export the v2 remote loader.");
|
||||
assert.match(catalogRemoteSource, /export async function tryLoadFixtureCatalog/, "catalog remote seam must export the fixture loader.");
|
||||
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Index/, "catalog remote seam must export the v3 index fetch/cache helper.");
|
||||
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Page/, "catalog remote seam must export the v3 page fetch/cache helper.");
|
||||
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Search/, "catalog remote seam must export the v3 search fetch/cache helper.");
|
||||
assert.match(catalogRemoteSource, /export async function getV2CatalogOrFixture/, "catalog remote seam must export the v2/fixture fallback helper.");
|
||||
assert.match(petWindowContentSource, /export async function createDefaultPetRender/, "familiar-window content helper must export default familiar rendering.");
|
||||
assert.match(petWindowContentSource, /export async function createInstalledPetRender/, "familiar-window content helper must export installed familiar rendering.");
|
||||
assert.match(petWindowLayoutSource, /export function getBasePetWindowSize/, "familiar-window-layout must export base window sizing.");
|
||||
|
|
|
|||
|
|
@ -285,7 +285,8 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
|
|||
- `familiar-paths.ts`: Safe path resolution for familiar directories
|
||||
- `codex-familiars.ts`: Import from `~/.codex/familiars/` with validation
|
||||
- `codex-familiars-core.ts`: Codex metadata validation constants
|
||||
- `catalog.ts`: Remote catalog fetch with V3 pagination support, search, fixture fallback
|
||||
- `catalog.ts`: Catalog UI-state assembly, v3 surfaceability paging, pet lookup, and fixture fallback presentation.
|
||||
- `catalog-remote.ts`: Extracted remote catalog v2/v3 fetch/cache plumbing, fixture loading, and bounded response validation.
|
||||
- `catalog-validation.ts`: CatalogV2/V3 schema validation
|
||||
- `zip-safety.ts`: ZIP entry path validation (traversal prevention, case collision detection)
|
||||
|
||||
|
|
|
|||
30
apps/desktop/tests/catalog-remote-seams.test.ts
Normal file
30
apps/desktop/tests/catalog-remote-seams.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const catalogSource = readFileSync(resolve(desktopRoot, "src/catalog.ts"), "utf8");
|
||||
const catalogRemoteSource = readFileSync(resolve(desktopRoot, "src/catalog-remote.ts"), "utf8");
|
||||
|
||||
assert.match(catalogSource, /from "\.\/catalog-remote(?:\.js)?"/, "catalog must import the extracted remote helper seam.");
|
||||
assert.match(catalogSource, /export \{ catalogUrl, catalogV3Url \} from "\.\/catalog-remote(?:\.js)?"/, "catalog must re-export catalog URLs through the extracted remote seam.");
|
||||
assert.match(catalogSource, /getRemoteCatalogV3Page/, "catalog must delegate v3 page reads through the extracted remote seam.");
|
||||
assert.match(catalogSource, /getRemoteCatalogV3Search/, "catalog must delegate v3 search reads through the extracted remote seam.");
|
||||
assert.match(catalogSource, /tryLoadRemoteCatalogV3Index/, "catalog must delegate v3 index reads through the extracted remote seam.");
|
||||
assert.match(catalogSource, /tryLoadRemoteCatalog/, "catalog must delegate v2 remote reads through the extracted remote seam.");
|
||||
assert.match(catalogSource, /tryLoadFixtureCatalog/, "catalog must delegate fixture fallback reads through the extracted remote seam.");
|
||||
assert.match(catalogSource, /getV2CatalogOrFixture/, "catalog must delegate v2-or-fixture fallback through the extracted remote seam.");
|
||||
|
||||
assert.match(catalogRemoteSource, /export const catalogUrl/, "catalog remote seam must export the v2 catalog URL.");
|
||||
assert.match(catalogRemoteSource, /export const catalogV3Url/, "catalog remote seam must export the v3 catalog URL.");
|
||||
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Index/, "catalog remote seam must export the v3 index loader.");
|
||||
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Page/, "catalog remote seam must export the v3 page loader.");
|
||||
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalog/, "catalog remote seam must export the v2 remote loader.");
|
||||
assert.match(catalogRemoteSource, /export async function tryLoadFixtureCatalog/, "catalog remote seam must export the fixture loader.");
|
||||
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Index/, "catalog remote seam must export the v3 index fetch/cache helper.");
|
||||
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Page/, "catalog remote seam must export the v3 page fetch/cache helper.");
|
||||
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Search/, "catalog remote seam must export the v3 search fetch/cache helper.");
|
||||
assert.match(catalogRemoteSource, /export async function getV2CatalogOrFixture/, "catalog remote seam must export the v2/fixture fallback helper.");
|
||||
|
||||
console.error("Catalog remote seam validation passed.");
|
||||
Loading…
Add table
Reference in a new issue