parent
f6f076122c
commit
89aed99850
9 changed files with 28 additions and 709 deletions
|
|
@ -3,7 +3,6 @@ const { contextBridge, ipcRenderer } = require("electron");
|
|||
const api = {
|
||||
getState: () => ipcRenderer.invoke("openpets:get-state"),
|
||||
getCatalog: () => ipcRenderer.invoke("openpets:get-catalog"),
|
||||
getCatalogPage: (pageIndex) => ipcRenderer.invoke("openpets:get-catalog-page", pageIndex),
|
||||
getCodexPets: () => ipcRenderer.invoke("openpets:get-codex-pets"),
|
||||
updatePreferences: (patch) => ipcRenderer.invoke("openpets:update-preferences", patch),
|
||||
getLaunchAtLogin: () => ipcRenderer.invoke("openpets:get-launch-at-login"),
|
||||
|
|
@ -37,10 +36,6 @@ let activePetManagerSelection = "";
|
|||
let activePetManagerFilter = "all";
|
||||
let activePetManagerItems = [];
|
||||
let activePetManagerDefaultId = "";
|
||||
let activePetManagerCatalogState = null;
|
||||
let activePetManagerCodexState = null;
|
||||
let activePetManagerAppState = null;
|
||||
let activePetManagerLoadingPage = false;
|
||||
|
||||
contextBridge.exposeInMainWorld("openPets", api);
|
||||
contextBridge.exposeInMainWorld("openpetsAgentSetup", agentSetupApi);
|
||||
|
|
@ -617,9 +612,6 @@ async function copyText(text, resultId = "claude-action-result", successMessage
|
|||
async function renderPetManager(state) {
|
||||
const defaultPetId = state.preferences.defaultPetId;
|
||||
const [catalogState, codexState] = await Promise.all([api.getCatalog(), api.getCodexPets()]);
|
||||
activePetManagerCatalogState = catalogState;
|
||||
activePetManagerCodexState = codexState;
|
||||
activePetManagerAppState = state;
|
||||
renderPetGallery(catalogState, codexState, state, defaultPetId);
|
||||
}
|
||||
|
||||
|
|
@ -638,12 +630,10 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) {
|
|||
return;
|
||||
}
|
||||
|
||||
const sourceLabel = catalogState.source === "remote" ? catalogState.version === 3 ? "Live v3" : "Live" : catalogState.source === "fixture" ? "Fixture" : "Error";
|
||||
const sourceLabel = catalogState.source === "remote" ? "Live" : catalogState.source === "fixture" ? "Fixture" : "Error";
|
||||
const codexLabel = codexState.error ? "Codex unavailable" : `${codexState.pets.length} Codex`;
|
||||
const totalLabel = catalogState.v3 ? `${catalogState.pets.length}/${catalogState.v3.total} pets` : `${catalogState.pets.length} pets`;
|
||||
status.textContent = catalogState.error ? `${sourceLabel}: ${catalogState.error} · ${codexLabel}` : `${sourceLabel}: ${totalLabel} · ${codexLabel}`;
|
||||
status.textContent = catalogState.error ? `${sourceLabel}: ${catalogState.error} · ${codexLabel}` : `${sourceLabel}: ${catalogState.pets.length} pets · ${codexLabel}`;
|
||||
status.className = `pm-status-pill ${catalogState.error || codexState.error ? "error" : "success"}`;
|
||||
updateCategoryFilterAvailability(catalogState);
|
||||
const pets = createPetManagerItems(catalogState, codexState, state, defaultPetId, defaultThumbnailSrc);
|
||||
activePetManagerItems = pets;
|
||||
activePetManagerDefaultId = defaultPetId;
|
||||
|
|
@ -670,7 +660,6 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) {
|
|||
const visiblePets = pets.filter((pet) => {
|
||||
if (activePetManagerFilter === "installed" && !pet.installed) return false;
|
||||
if (activePetManagerFilter === "codex" && !pet.codexPet && !pet.codexImported) return false;
|
||||
if ((activePetManagerFilter === "western" || activePetManagerFilter === "asian") && pet.category !== activePetManagerFilter) return false;
|
||||
const haystack = `${pet.id} ${pet.displayName} ${pet.description}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
|
|
@ -688,20 +677,6 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) {
|
|||
grid.append(empty);
|
||||
}
|
||||
|
||||
if (catalogState.v3?.hasMore) {
|
||||
const hint = document.createElement("div");
|
||||
hint.className = "pm-page-hint";
|
||||
hint.textContent = "Showing loaded pets only. Load more to continue browsing and filtering the full catalog.";
|
||||
grid.append(hint);
|
||||
const loadMore = document.createElement("button");
|
||||
loadMore.className = "pm-load-more";
|
||||
loadMore.type = "button";
|
||||
loadMore.textContent = activePetManagerLoadingPage ? "Loading…" : "Load more pets";
|
||||
loadMore.disabled = activePetManagerLoadingPage;
|
||||
loadMore.addEventListener("click", () => { void loadNextCatalogPage(); });
|
||||
grid.append(loadMore);
|
||||
}
|
||||
|
||||
const selected = visiblePets.find((pet) => pet.id === activePetManagerSelection) || visiblePets[0] || pets.find((pet) => pet.id === activePetManagerSelection) || pets[0];
|
||||
if (selected) {
|
||||
renderPetDetail(detail, selected, defaultPetId);
|
||||
|
|
@ -712,36 +687,6 @@ function renderPetGallery(catalogState, codexState, state, defaultPetId) {
|
|||
render();
|
||||
}
|
||||
|
||||
function updateCategoryFilterAvailability(catalogState) {
|
||||
const v3Available = catalogState.version === 3 && catalogState.v3;
|
||||
for (const id of ["western", "asian"]) {
|
||||
const button = document.querySelector(`[data-pet-filter="${id}"]`);
|
||||
if (!(button instanceof HTMLButtonElement)) continue;
|
||||
button.hidden = !v3Available;
|
||||
button.disabled = !v3Available;
|
||||
}
|
||||
if (!v3Available && (activePetManagerFilter === "western" || activePetManagerFilter === "asian")) activePetManagerFilter = "all";
|
||||
}
|
||||
|
||||
async function loadNextCatalogPage() {
|
||||
if (activePetManagerLoadingPage || !activePetManagerCatalogState?.v3) return;
|
||||
const loaded = activePetManagerCatalogState.v3.loadedPages || [];
|
||||
const nextPage = loaded.length ? Math.max(...loaded) + 1 : 0;
|
||||
activePetManagerLoadingPage = true;
|
||||
try {
|
||||
const catalogState = await api.getCatalogPage(nextPage);
|
||||
activePetManagerCatalogState = catalogState;
|
||||
activePetManagerLoadingPage = false;
|
||||
if (activePetManagerCodexState && activePetManagerAppState) {
|
||||
renderPetGallery(catalogState, activePetManagerCodexState, activePetManagerAppState, activePetManagerAppState.preferences.defaultPetId);
|
||||
}
|
||||
} catch (error) {
|
||||
renderCaughtError(error);
|
||||
} finally {
|
||||
activePetManagerLoadingPage = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createPetManagerItems(catalogState, codexState, state, defaultPetId, defaultThumbnailSrc) {
|
||||
const installedById = new Map(state.pets.installed.map((pet) => [pet.id, pet]));
|
||||
const codexById = new Map(codexState.pets.map((pet) => [pet.id, pet]));
|
||||
|
|
@ -769,11 +714,8 @@ function createPetManagerItems(catalogState, codexState, state, defaultPetId, de
|
|||
}
|
||||
|
||||
function createPetManagerItem(id, displayName, description, installed, catalogPet, codexPet, codexImported, defaultPetId, defaultThumbnailSrc) {
|
||||
const catalogPreview = catalogPet?.thumbnail || catalogPet?.preview || "";
|
||||
const installedPreview = installed?.source?.kind !== "codex" && isAllowedCatalogPreview(installed?.source?.preview) ? installed.source.preview : "";
|
||||
const preview = codexPet?.preview || (catalogPet && isAllowedCatalogPreview(catalogPreview) ? catalogPreview : "") || installedPreview;
|
||||
const preview = codexPet?.preview || (catalogPet && isAllowedCatalogPreview(catalogPet.preview) ? catalogPet.preview : "");
|
||||
const usesThumbnail = Boolean(installed?.builtIn && defaultThumbnailSrc);
|
||||
const previewIsThumbnail = usesThumbnail || Boolean(catalogPet?.thumbnail) || Boolean(installedPreview && installed?.source?.catalogVersion === 3 && preview === installedPreview);
|
||||
return {
|
||||
id,
|
||||
displayName,
|
||||
|
|
@ -783,9 +725,8 @@ function createPetManagerItem(id, displayName, description, installed, catalogPe
|
|||
codexPet,
|
||||
codexImported,
|
||||
previewSrc: usesThumbnail ? defaultThumbnailSrc : preview,
|
||||
previewIsSpriteSheet: !previewIsThumbnail,
|
||||
previewIsSpriteSheet: !usesThumbnail,
|
||||
isDefault: id === defaultPetId,
|
||||
category: catalogPet?.category || "",
|
||||
protected: Boolean(installed?.protected),
|
||||
broken: Boolean(installed?.broken),
|
||||
brokenReason: installed?.brokenReason || "",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export interface InstalledPetState {
|
|||
readonly installed: boolean;
|
||||
readonly source?: {
|
||||
readonly kind?: "catalog";
|
||||
readonly catalogVersion: 2 | 3;
|
||||
readonly catalogVersion: 2;
|
||||
readonly zip: string;
|
||||
readonly preview: string;
|
||||
} | {
|
||||
|
|
@ -405,11 +405,11 @@ function normalizeSource(value: unknown): InstalledPetState["source"] | undefine
|
|||
return { kind: "codex", path: value.path };
|
||||
}
|
||||
|
||||
if ((value.catalogVersion !== 2 && value.catalogVersion !== 3) || typeof value.zip !== "string" || typeof value.preview !== "string") return undefined;
|
||||
if (value.catalogVersion !== 2 || typeof value.zip !== "string" || typeof value.preview !== "string") return undefined;
|
||||
|
||||
return {
|
||||
kind: "catalog",
|
||||
catalogVersion: value.catalogVersion,
|
||||
catalogVersion: 2,
|
||||
zip: value.zip,
|
||||
preview: value.preview,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,41 +12,6 @@ export interface CatalogPetV2 {
|
|||
readonly zip: string;
|
||||
}
|
||||
|
||||
export interface CatalogV3Index {
|
||||
readonly version: 3;
|
||||
readonly generatedAt: string;
|
||||
readonly total: number;
|
||||
readonly pageSize: number;
|
||||
readonly filters: { readonly categories: readonly CatalogV3Category[] };
|
||||
readonly pages: readonly string[];
|
||||
}
|
||||
|
||||
export interface CatalogV3Category {
|
||||
readonly id: "western" | "asian";
|
||||
readonly label: string;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface CatalogV3Page {
|
||||
readonly version: 3;
|
||||
readonly generatedAt: string;
|
||||
readonly page: number;
|
||||
readonly pageSize: number;
|
||||
readonly pets: readonly CatalogPetV3[];
|
||||
}
|
||||
|
||||
export interface CatalogPetV3 {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly description: string;
|
||||
readonly thumbnail: string;
|
||||
readonly preview?: string;
|
||||
readonly spritesheet?: string;
|
||||
readonly zip: string;
|
||||
readonly category: "western" | "asian";
|
||||
readonly subcategory?: string;
|
||||
}
|
||||
|
||||
export function validateCatalogV2(value: unknown): CatalogV2 {
|
||||
if (!isRecord(value)) throw new Error("Catalog must be an object.");
|
||||
if (value.version !== 2) throw new Error("Catalog version must be 2.");
|
||||
|
|
@ -64,32 +29,6 @@ export function validateCatalogV2(value: unknown): CatalogV2 {
|
|||
};
|
||||
}
|
||||
|
||||
export function validateCatalogV3Index(value: unknown): CatalogV3Index {
|
||||
if (!isRecord(value)) throw new Error("Catalog v3 index must be an object.");
|
||||
if (value.version !== 3) throw new Error("Catalog index version must be 3.");
|
||||
if (typeof value.generatedAt !== "string" || Number.isNaN(Date.parse(value.generatedAt))) throw new Error("Catalog generatedAt must be a valid date string.");
|
||||
if (!Number.isSafeInteger(value.total) || value.total < 0 || value.total > 20_000) throw new Error("Catalog total is invalid.");
|
||||
if (!Number.isSafeInteger(value.pageSize) || value.pageSize <= 0 || value.pageSize > 200) throw new Error("Catalog pageSize is invalid.");
|
||||
if (!Array.isArray(value.pages) || value.pages.length > 100) throw new Error("Catalog pages are invalid.");
|
||||
const pages = value.pages.map((page, index) => validateCatalogPageUrl(page, index));
|
||||
const filters = validateCatalogV3Filters(value.filters);
|
||||
if (pages.length !== Math.ceil(value.total / value.pageSize)) throw new Error("Catalog page count does not match total/pageSize.");
|
||||
if (filters.categories.reduce((total, category) => total + category.count, 0) !== value.total) throw new Error("Catalog category counts do not match total.");
|
||||
return { version: 3, generatedAt: value.generatedAt, total: value.total, pageSize: value.pageSize, filters, pages };
|
||||
}
|
||||
|
||||
export function validateCatalogV3Page(value: unknown, expectedPage: number, expectedPageSize: number): CatalogV3Page {
|
||||
if (!isRecord(value)) throw new Error("Catalog v3 page must be an object.");
|
||||
if (value.version !== 3) throw new Error("Catalog page version must be 3.");
|
||||
if (typeof value.generatedAt !== "string" || Number.isNaN(Date.parse(value.generatedAt))) throw new Error("Catalog page generatedAt must be a valid date string.");
|
||||
if (value.page !== expectedPage) throw new Error("Catalog page index does not match requested page.");
|
||||
if (value.pageSize !== expectedPageSize) throw new Error("Catalog page size does not match index.");
|
||||
if (!Array.isArray(value.pets) || value.pets.length > expectedPageSize) throw new Error("Catalog page pets are invalid.");
|
||||
const ids = new Set<string>();
|
||||
const pets = value.pets.map((pet) => validateCatalogPetV3(pet, ids));
|
||||
return { version: 3, generatedAt: value.generatedAt, page: expectedPage, pageSize: expectedPageSize, pets };
|
||||
}
|
||||
|
||||
function validateCatalogPet(value: unknown, ids: Set<string>): CatalogPetV2 {
|
||||
if (!isRecord(value)) throw new Error("Catalog pet must be an object.");
|
||||
const id = validateId(value.id);
|
||||
|
|
@ -106,26 +45,6 @@ function validateCatalogPet(value: unknown, ids: Set<string>): CatalogPetV2 {
|
|||
};
|
||||
}
|
||||
|
||||
function validateCatalogPetV3(value: unknown, ids: Set<string>): CatalogPetV3 {
|
||||
if (!isRecord(value)) throw new Error("Catalog v3 pet must be an object.");
|
||||
const id = validateId(value.id);
|
||||
if (ids.has(id)) throw new Error(`Duplicate catalog v3 pet id on page: ${id}`);
|
||||
ids.add(id);
|
||||
const category = validateCategory(value.category);
|
||||
const pet: CatalogPetV3 = {
|
||||
id,
|
||||
displayName: validateString(value.displayName, "displayName", 120),
|
||||
description: validateString(value.description, "description", 500),
|
||||
thumbnail: validateCatalogUrl(value.thumbnail, "thumbnail"),
|
||||
zip: validateCatalogUrl(value.zip, "zip"),
|
||||
category,
|
||||
};
|
||||
const preview = validateOptionalCatalogUrl(value.preview, "preview");
|
||||
const spritesheet = validateOptionalCatalogUrl(value.spritesheet, "spritesheet");
|
||||
const subcategory = typeof value.subcategory === "string" ? validateString(value.subcategory, "subcategory", 80) : undefined;
|
||||
return { ...pet, ...(preview ? { preview } : {}), ...(spritesheet ? { spritesheet } : {}), ...(subcategory ? { subcategory } : {}) };
|
||||
}
|
||||
|
||||
function validateId(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("Catalog pet id must be a string.");
|
||||
if (value === "builtin") throw new Error("Catalog pet id 'builtin' is reserved.");
|
||||
|
|
@ -139,7 +58,7 @@ function validateString(value: unknown, field: string, maxLength: number): strin
|
|||
return value;
|
||||
}
|
||||
|
||||
function validateCatalogUrl(value: unknown, field: "preview" | "zip" | "thumbnail" | "spritesheet"): string {
|
||||
function validateCatalogUrl(value: unknown, field: "preview" | "zip"): string {
|
||||
const raw = validateString(value, field, 2048);
|
||||
const url = new URL(raw);
|
||||
|
||||
|
|
@ -147,9 +66,8 @@ function validateCatalogUrl(value: unknown, field: "preview" | "zip" | "thumbnai
|
|||
if (url.username || url.password) throw new Error(`${field} URL cannot include credentials.`);
|
||||
if (url.port) throw new Error(`${field} URL cannot include a custom port.`);
|
||||
|
||||
if (field === "preview" || field === "thumbnail" || field === "spritesheet") {
|
||||
if (field === "preview") {
|
||||
if (url.hostname !== "openpets.dev" || !url.pathname.startsWith("/pets/")) throw new Error("Preview URL host/path is not allowed.");
|
||||
if (!url.pathname.endsWith(".webp")) throw new Error(`${field} URL must be a WebP image.`);
|
||||
} else if (url.hostname !== "zip.openpets.dev" || !url.pathname.startsWith("/pets/")) {
|
||||
throw new Error("Zip URL host/path is not allowed.");
|
||||
}
|
||||
|
|
@ -157,41 +75,6 @@ function validateCatalogUrl(value: unknown, field: "preview" | "zip" | "thumbnai
|
|||
return url.toString();
|
||||
}
|
||||
|
||||
function validateOptionalCatalogUrl(value: unknown, field: "preview" | "spritesheet"): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return validateCatalogUrl(value, field);
|
||||
}
|
||||
|
||||
function validateCatalogV3Filters(value: unknown): { readonly categories: readonly CatalogV3Category[] } {
|
||||
if (!isRecord(value) || !Array.isArray(value.categories)) throw new Error("Catalog v3 filters are invalid.");
|
||||
const categories = value.categories.map(validateCatalogV3Category);
|
||||
const ids = new Set(categories.map((category) => category.id));
|
||||
if (ids.size !== categories.length || !ids.has("western") || !ids.has("asian")) throw new Error("Catalog v3 category filters are incomplete or duplicated.");
|
||||
return { categories };
|
||||
}
|
||||
|
||||
function validateCatalogV3Category(value: unknown): CatalogV3Category {
|
||||
if (!isRecord(value)) throw new Error("Catalog v3 category must be an object.");
|
||||
const id = validateCategory(value.id);
|
||||
if (typeof value.label !== "string" || value.label.length > 40) throw new Error("Catalog v3 category label is invalid.");
|
||||
if (!Number.isSafeInteger(value.count) || value.count < 0) throw new Error("Catalog v3 category count is invalid.");
|
||||
return { id, label: value.label, count: value.count };
|
||||
}
|
||||
|
||||
function validateCatalogPageUrl(value: unknown, index: number): string {
|
||||
const raw = validateString(value, "page", 2048);
|
||||
const url = new URL(raw);
|
||||
if (url.protocol !== "https:" || url.hostname !== "openpets.dev" || url.port || url.username || url.password) throw new Error("Catalog page URL origin is not allowed.");
|
||||
if (url.search || url.hash) throw new Error("Catalog page URL cannot include query or hash.");
|
||||
if (url.pathname !== `/pets/catalog.v3/page-${String(index).padStart(3, "0")}.json`) throw new Error("Catalog page URL path is not allowed.");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function validateCategory(value: unknown): "western" | "asian" {
|
||||
if (value === "western" || value === "asian") return value;
|
||||
throw new Error("Catalog pet category is invalid.");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,68 +3,28 @@ import { join } from "node:path";
|
|||
|
||||
import { app } from "electron";
|
||||
|
||||
import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, type CatalogPetV2, type CatalogPetV3, type CatalogV2, type CatalogV3Index, type CatalogV3Page } from "./catalog-validation.js";
|
||||
import { validateCatalogV2, type CatalogPetV2, type CatalogV2 } from "./catalog-validation.js";
|
||||
|
||||
export const catalogUrl = "https://openpets.dev/pets/catalog.v2.json";
|
||||
export const catalogV3Url = "https://openpets.dev/pets/catalog.v3.json";
|
||||
const fixtureRelativePath = "catalog.v2.fixture.json";
|
||||
const maxCatalogBytes = 1_000_000;
|
||||
const maxCatalogV3IndexBytes = 256 * 1024;
|
||||
const maxCatalogV3PageBytes = 256 * 1024;
|
||||
const fetchTimeoutMs = 5_000;
|
||||
|
||||
export interface CatalogUiState {
|
||||
readonly source: "remote" | "fixture" | "error";
|
||||
readonly version: 2 | 3;
|
||||
readonly pets: readonly CatalogUiPet[];
|
||||
readonly pets: readonly CatalogPetV2[];
|
||||
readonly generatedAt?: string;
|
||||
readonly error?: string;
|
||||
readonly v3?: CatalogV3UiState;
|
||||
}
|
||||
|
||||
export type CatalogUiPet = CatalogPetV2 | CatalogPetV3;
|
||||
|
||||
export interface CatalogV3UiState {
|
||||
readonly total: number;
|
||||
readonly pageSize: number;
|
||||
readonly loadedPages: readonly number[];
|
||||
readonly hasMore: boolean;
|
||||
readonly filters: CatalogV3Index["filters"];
|
||||
}
|
||||
|
||||
const v3PageCache = new Map<number, CatalogV3Page>();
|
||||
let v3IndexCache: CatalogV3Index | null = null;
|
||||
|
||||
export async function getCatalogUiState(): Promise<CatalogUiState> {
|
||||
const v3 = await tryLoadRemoteCatalogV3FirstPage();
|
||||
|
||||
if (v3.ok) {
|
||||
const loadedPages = [...new Set([...v3PageCache.keys()])].sort((left, right) => left - right);
|
||||
const pets = loadedPages.flatMap((loadedPage) => v3PageCache.get(loadedPage)?.pets ?? []);
|
||||
return {
|
||||
source: "remote",
|
||||
version: 3,
|
||||
pets,
|
||||
generatedAt: v3.index.generatedAt,
|
||||
v3: {
|
||||
total: v3.index.total,
|
||||
pageSize: v3.index.pageSize,
|
||||
loadedPages,
|
||||
hasMore: loadedPages.length < v3.index.pages.length,
|
||||
filters: v3.index.filters,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const remote = await tryLoadRemoteCatalog();
|
||||
|
||||
if (remote.ok) {
|
||||
return {
|
||||
source: "remote",
|
||||
version: 2,
|
||||
pets: remote.catalog.pets,
|
||||
generatedAt: remote.catalog.generatedAt,
|
||||
error: `Catalog v3 unavailable: ${v3.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -73,65 +33,19 @@ export async function getCatalogUiState(): Promise<CatalogUiState> {
|
|||
if (fixture.ok) {
|
||||
return {
|
||||
source: "fixture",
|
||||
version: 2,
|
||||
pets: fixture.catalog.pets,
|
||||
generatedAt: fixture.catalog.generatedAt,
|
||||
error: `Catalog v3 unavailable: ${v3.error}. Live catalog unavailable: ${remote.error}`,
|
||||
error: `Live catalog unavailable: ${remote.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: "error",
|
||||
version: 2,
|
||||
pets: [],
|
||||
error: `Catalog v3 unavailable: ${v3.error}. Live catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`,
|
||||
error: `Live catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCatalogPageUiState(pageIndex: number): Promise<CatalogUiState> {
|
||||
if (!Number.isSafeInteger(pageIndex) || pageIndex < 0) throw new Error("Catalog page index is invalid.");
|
||||
const index = await getCatalogV3Index();
|
||||
const page = await getCatalogV3Page(index, pageIndex);
|
||||
const loadedPages = [...new Set([...v3PageCache.keys()])].sort((left, right) => left - right);
|
||||
const pets = loadedPages.flatMap((loadedPage) => v3PageCache.get(loadedPage)?.pets ?? []);
|
||||
return {
|
||||
source: "remote",
|
||||
version: 3,
|
||||
pets,
|
||||
generatedAt: index.generatedAt,
|
||||
v3: {
|
||||
total: index.total,
|
||||
pageSize: index.pageSize,
|
||||
loadedPages,
|
||||
hasMore: loadedPages.length < index.pages.length,
|
||||
filters: index.filters,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCatalogPetById(petId: string): Promise<CatalogUiPet> {
|
||||
const v3 = await tryLoadRemoteCatalogV3FirstPage();
|
||||
if (v3.ok) {
|
||||
const cached = findCachedV3Pet(petId);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
for (let pageIndex = 0; pageIndex < v3.index.pages.length; pageIndex += 1) {
|
||||
const page = await getCatalogV3Page(v3.index, pageIndex);
|
||||
const pet = page.pets.find((candidate) => candidate.id === petId);
|
||||
if (pet) return pet;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the compatibility catalog if any later v3 page is unavailable or invalid.
|
||||
}
|
||||
}
|
||||
const remote = await tryLoadRemoteCatalog();
|
||||
const fixture = remote.ok ? null : await tryLoadFixtureCatalog();
|
||||
const fallback = remote.ok ? remote.catalog : fixture?.ok ? fixture.catalog : null;
|
||||
const pet = fallback?.pets.find((candidate) => candidate.id === petId);
|
||||
if (!pet) throw new Error(`Pet is not available in the validated catalog: ${petId}`);
|
||||
return pet;
|
||||
}
|
||||
|
||||
async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
||||
|
|
@ -155,53 +69,6 @@ async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly cat
|
|||
}
|
||||
}
|
||||
|
||||
async function tryLoadRemoteCatalogV3FirstPage(): Promise<{ readonly ok: true; readonly index: CatalogV3Index; readonly page: CatalogV3Page } | { readonly ok: false; readonly error: string }> {
|
||||
try {
|
||||
const index = await getCatalogV3Index();
|
||||
const page = await getCatalogV3Page(index, 0);
|
||||
return { ok: true, index, page };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
async function getCatalogV3Index(): Promise<CatalogV3Index> {
|
||||
if (v3IndexCache) return v3IndexCache;
|
||||
const response = await fetchBounded(catalogV3Url, maxCatalogV3IndexBytes);
|
||||
validateCatalogV3IndexEndpoint(response.url);
|
||||
v3IndexCache = validateCatalogV3Index(JSON.parse(response.text) as unknown);
|
||||
return v3IndexCache;
|
||||
}
|
||||
|
||||
async function getCatalogV3Page(index: CatalogV3Index, pageIndex: number): Promise<CatalogV3Page> {
|
||||
const cached = v3PageCache.get(pageIndex);
|
||||
if (cached) return cached;
|
||||
const pageUrl = index.pages[pageIndex];
|
||||
if (!pageUrl) throw new Error(`Catalog page is unavailable: ${pageIndex}`);
|
||||
const response = await fetchBounded(pageUrl, maxCatalogV3PageBytes);
|
||||
if (response.url !== pageUrl) throw new Error("Catalog page final URL changed.");
|
||||
const page = validateCatalogV3Page(JSON.parse(response.text) as unknown, pageIndex, index.pageSize);
|
||||
for (const cachedPage of v3PageCache.values()) {
|
||||
for (const pet of page.pets) {
|
||||
if (cachedPage.pets.some((cachedPet) => cachedPet.id === pet.id)) throw new Error(`Duplicate catalog v3 pet id across pages: ${pet.id}`);
|
||||
}
|
||||
}
|
||||
v3PageCache.set(pageIndex, page);
|
||||
return page;
|
||||
}
|
||||
|
||||
async function fetchBounded(url: string, maxBytes: number): Promise<{ readonly url: string; readonly text: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal, redirect: "error", credentials: "omit" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return { url: response.url, text: 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()) };
|
||||
|
|
@ -215,7 +82,7 @@ async function loadFixtureCatalog(): Promise<unknown> {
|
|||
return JSON.parse(await readFile(fixturePath, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
async function readLimitedResponse(response: Response, maxBytes = maxCatalogBytes): Promise<string> {
|
||||
async function readLimitedResponse(response: Response): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog response body is unavailable for bounded reading.");
|
||||
|
||||
|
|
@ -226,7 +93,7 @@ async function readLimitedResponse(response: Response, maxBytes = maxCatalogByte
|
|||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) throw new Error("Catalog response is too large.");
|
||||
if (total > maxCatalogBytes) throw new Error("Catalog response is too large.");
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
|
|
@ -247,16 +114,3 @@ function validateCatalogEndpoint(value: string): void {
|
|||
const url = new URL(value);
|
||||
if (url.href !== catalogUrl) throw new Error("Catalog final URL is not allowed.");
|
||||
}
|
||||
|
||||
function validateCatalogV3IndexEndpoint(value: string): void {
|
||||
const url = new URL(value);
|
||||
if (url.href !== catalogV3Url) throw new Error("Catalog v3 final URL is not allowed.");
|
||||
}
|
||||
|
||||
function findCachedV3Pet(petId: string): CatalogPetV3 | undefined {
|
||||
for (const page of v3PageCache.values()) {
|
||||
const pet = page.pets.find((candidate) => candidate.id === petId);
|
||||
if (pet) return pet;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page } from "./catalog-validation.js";
|
||||
import { validateCatalogV2 } from "./catalog-validation.js";
|
||||
|
||||
const fixture = JSON.parse(await readFile("catalog.v2.fixture.json", "utf8")) as unknown;
|
||||
validateCatalogV2(fixture);
|
||||
|
|
@ -17,48 +17,6 @@ for (const invalidCase of invalidCases) {
|
|||
assertRejectsCatalog(invalidCase);
|
||||
}
|
||||
|
||||
const v3Index = validateCatalogV3Index({
|
||||
version: 3,
|
||||
generatedAt: new Date().toISOString(),
|
||||
total: 1,
|
||||
pageSize: 100,
|
||||
filters: { categories: [{ id: "western", label: "Western", count: 1 }, { id: "asian", label: "Asian", count: 0 }] },
|
||||
pages: ["https://openpets.dev/pets/catalog.v3/page-000.json"],
|
||||
});
|
||||
|
||||
validateCatalogV3Page({
|
||||
version: 3,
|
||||
generatedAt: v3Index.generatedAt,
|
||||
page: 0,
|
||||
pageSize: 100,
|
||||
pets: [{
|
||||
id: "snoopy",
|
||||
displayName: "Snoopy",
|
||||
description: "A tiny beagle.",
|
||||
thumbnail: "https://openpets.dev/pets/snoopy-23e05847/thumb.webp",
|
||||
spritesheet: "https://openpets.dev/pets/snoopy-23e05847/spritesheet.webp",
|
||||
zip: "https://zip.openpets.dev/pets/snoopy-23e05847/snoopy.zip",
|
||||
category: "western",
|
||||
subcategory: "cartoons",
|
||||
}],
|
||||
}, 0, 100);
|
||||
|
||||
assertRejectsCatalogV3Page({
|
||||
version: 3,
|
||||
generatedAt: new Date().toISOString(),
|
||||
page: 0,
|
||||
pageSize: 100,
|
||||
pets: [{ id: "bad", displayName: "Bad", description: "", thumbnail: "https://openpets.dev/pets/bad/spritesheet.png", zip: "https://zip.openpets.dev/pets/bad/bad.zip", category: "western" }],
|
||||
});
|
||||
|
||||
assertRejectsCatalogV3Page({
|
||||
version: 3,
|
||||
generatedAt: new Date().toISOString(),
|
||||
page: 0,
|
||||
pageSize: 100,
|
||||
pets: [{ id: "bad", displayName: "Bad", description: "", thumbnail: "https://openpets.dev/pets/bad/thumb.webp", zip: "https://zip.openpets.dev/pets/bad/bad.zip", category: "unknown" }],
|
||||
});
|
||||
|
||||
console.log("Catalog fixture validation passed.");
|
||||
|
||||
function assertRejectsCatalog(value: unknown): void {
|
||||
|
|
@ -70,13 +28,3 @@ function assertRejectsCatalog(value: unknown): void {
|
|||
|
||||
throw new Error("Invalid catalog fixture case was accepted.");
|
||||
}
|
||||
|
||||
function assertRejectsCatalogV3Page(value: unknown): void {
|
||||
try {
|
||||
validateCatalogV3Page(value, 0, 100);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Invalid catalog v3 page case was accepted.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,11 +159,6 @@ assert.ok(windowsSource.includes(`content="default-src 'none'; img-src data:; st
|
|||
assert.doesNotMatch(windowsSource, /data-default-pet-sprite-src|createAssetDataUrl\("default-pet-spritesheet\.webp"/, "Pet Manager must not embed the large default spritesheet into the task-window data URL.");
|
||||
assert.match(windowsSource, /default-pet-thumbnail\.png/, "Pet Manager must use the small bundled default pet thumbnail for built-in preview.");
|
||||
assert.ok(windowsSource.includes(`content="default-src 'none'; img-src data: https://openpets.dev; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"`), "Pet Manager image CSP must stay scoped to data URLs and openpets.dev catalog previews.");
|
||||
assert.match(windowsSource, /data-pet-filter="codex"/, "Pet Manager must preserve the Codex filter.");
|
||||
assert.match(windowsSource, /data-pet-filter="western"/, "Pet Manager must expose a Western catalog filter when v3 metadata is available.");
|
||||
assert.match(windowsSource, /data-pet-filter="asian"/, "Pet Manager must expose an Asian catalog filter when v3 metadata is available.");
|
||||
assert.match(preloadSource, /api\.getCatalogPage/, "Pet Manager preload must support paged catalog loading.");
|
||||
assert.match(preloadSource, /catalogPet\?\.thumbnail/, "Pet Manager cards must prefer lightweight v3 thumbnails over full spritesheets.");
|
||||
assert.match(windowsSource, /petManagerWindowWidth\s*=\s*1160/, "Pet Manager should use the approved wider 1160px default window width.");
|
||||
assert.match(windowsSource, /petManagerWindowHeight\s*=\s*780/, "Pet Manager should use the approved taller 780px default window height.");
|
||||
assert.match(agentSetupHtmlSource, /content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"/, "Agent Setup image CSP must stay data-only for the bundled logo asset.");
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import yauzl from "yauzl";
|
|||
import type { Entry, ZipFile } from "yauzl";
|
||||
|
||||
import { getAppStateSnapshot, installPetState, removePetState, setDefaultPet, type OpenPetsStateV1 } from "./app-state.js";
|
||||
import { getCatalogPetById } from "./catalog.js";
|
||||
import type { CatalogPetV2, CatalogPetV3 } from "./catalog-validation.js";
|
||||
import { getCatalogUiState } from "./catalog.js";
|
||||
import type { CatalogPetV2 } from "./catalog-validation.js";
|
||||
import { builtInPet } from "./built-in-pet.js";
|
||||
import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./pet-paths.js";
|
||||
import { assertOutputPathInside, hasSupportedZipMagic, ZipEntryPathTracker } from "./zip-safety.js";
|
||||
|
|
@ -51,9 +51,9 @@ export async function installPet(petId: string): Promise<OpenPetsStateV1> {
|
|||
displayName: catalogPet.displayName,
|
||||
description: catalogPet.description,
|
||||
source: {
|
||||
catalogVersion: "thumbnail" in catalogPet ? 3 : 2,
|
||||
catalogVersion: 2,
|
||||
zip: catalogPet.zip,
|
||||
preview: "preview" in catalogPet && catalogPet.preview ? catalogPet.preview : "thumbnail" in catalogPet ? catalogPet.thumbnail : catalogPet.preview,
|
||||
preview: catalogPet.preview,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -106,8 +106,13 @@ export async function withPetOperation<T>(key: string, callback: () => Promise<T
|
|||
}
|
||||
}
|
||||
|
||||
async function getCatalogPet(petId: string): Promise<CatalogPetV2 | CatalogPetV3> {
|
||||
return getCatalogPetById(petId);
|
||||
async function getCatalogPet(petId: string): Promise<CatalogPetV2> {
|
||||
const catalog = await getCatalogUiState();
|
||||
const pet = catalog.pets.find((candidate) => candidate.id === petId);
|
||||
if (!pet) {
|
||||
throw new Error(`Pet is not available in the validated catalog: ${petId}`);
|
||||
}
|
||||
return pet;
|
||||
}
|
||||
|
||||
async function downloadPetZip(zipUrl: string): Promise<Buffer> {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { app, BrowserWindow, ipcMain, type IpcMainInvokeEvent } from "electron";
|
|||
import { getAgentSetupSnapshot, runAgentSetupAction, updateAgentSetupCommandPaths } from "./agent-setup.js";
|
||||
import { refreshAgentPetContent } from "./agent-pet-controller.js";
|
||||
import { completeOnboarding, getAppStateSnapshot, normalizePetScale, petScaleOptions, updatePreferences } from "./app-state.js";
|
||||
import { getCatalogPageUiState, getCatalogUiState } from "./catalog.js";
|
||||
import { getCatalogUiState } from "./catalog.js";
|
||||
import { getCodexPetsUiState, importCodexPet } from "./codex-pets.js";
|
||||
import { refreshDefaultPetContent, resetDefaultPetToInitialPosition } from "./default-pet-controller.js";
|
||||
import { installPet, removePet, setDefaultInstalledPet } from "./pet-installation.js";
|
||||
|
|
@ -99,12 +99,6 @@ export function installInternalUiHandlers(): void {
|
|||
return getCatalogUiState();
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:get-catalog-page", async (event, pageIndex: unknown) => {
|
||||
assertAllowedSender(event, ["pet-manager"]);
|
||||
if (!Number.isSafeInteger(pageIndex) || pageIndex < 0) throw new Error("Invalid catalog page index.");
|
||||
return getCatalogPageUiState(pageIndex);
|
||||
});
|
||||
|
||||
ipcMain.handle("openpets:get-codex-pets", async (event) => {
|
||||
assertAllowedSender(event, ["pet-manager"]);
|
||||
return getCodexPetsUiState();
|
||||
|
|
@ -385,8 +379,6 @@ function createPetManagerHtml(definition: TaskWindowDefinition): string {
|
|||
<button id="pm-filter-all" class="pm-filter active" type="button" data-pet-filter="all" aria-pressed="true">All</button>
|
||||
<button id="pm-filter-installed" class="pm-filter" type="button" data-pet-filter="installed" aria-pressed="false">Installed</button>
|
||||
<button id="pm-filter-codex" class="pm-filter" type="button" data-pet-filter="codex" aria-pressed="false">Codex</button>
|
||||
<button id="pm-filter-western" class="pm-filter" type="button" data-pet-filter="western" aria-pressed="false" hidden>Western</button>
|
||||
<button id="pm-filter-asian" class="pm-filter" type="button" data-pet-filter="asian" aria-pressed="false" hidden>Asian</button>
|
||||
</div>
|
||||
<span id="catalog-status" class="pm-status-pill">Loading…</span>
|
||||
</div>
|
||||
|
|
@ -1032,9 +1024,6 @@ function createTaskWindowStyles(): string {
|
|||
body[data-openpets-view="pet-manager"] .pm-detail-actions button:disabled { opacity: 1; cursor: default; background: linear-gradient(180deg, #ecf5ff, #dbeafe); color: #176df2; box-shadow: inset 0 1px 0 rgba(255,255,255,0.9); }
|
||||
body[data-openpets-view="pet-manager"] .pm-detail-actions button:only-child { grid-column: 1 / -1; }
|
||||
body[data-openpets-view="pet-manager"] .pm-empty-state { grid-column: 1 / -1; padding: 28px; text-align: center; color: #667694; border: 1px dashed rgba(126, 161, 210, 0.48); border-radius: 18px; background: rgba(255,255,255,0.5); }
|
||||
body[data-openpets-view="pet-manager"] .pm-load-more { grid-column: 1 / -1; border: 1px solid rgba(59, 130, 246, 0.26); border-radius: 16px; background: rgba(255,255,255,0.78); color: #1d4ed8; font-weight: 800; padding: 12px 14px; cursor: pointer; }
|
||||
body[data-openpets-view="pet-manager"] .pm-load-more:disabled { cursor: wait; opacity: 0.66; }
|
||||
body[data-openpets-view="pet-manager"] .pm-page-hint { grid-column: 1 / -1; color: #667694; font-size: 12px; text-align: center; }
|
||||
body[data-openpets-view="pet-manager"] [data-error] { position: fixed; left: 18px; right: 18px; bottom: 8px; margin: 0; color: #b91c1c; pointer-events: none; }
|
||||
@keyframes pm-idle-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
|
||||
@keyframes pm-sprite-idle { from { background-position: 0 0; } to { background-position: 85.714% 0; } }
|
||||
|
|
|
|||
|
|
@ -1,296 +0,0 @@
|
|||
# Phase 20: Scalable Pet Catalog and Lightweight Picker Images
|
||||
|
||||
## Goal
|
||||
|
||||
Scale the public pet catalog and desktop Pet Manager beyond 1,000 pets without loading full spritesheets for every visible card.
|
||||
|
||||
The clean solution is to move lightweight preview generation into the `web/` catalog pipeline and have desktop consume paginated catalog metadata with small thumbnail assets.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not change Codex/local pet discovery, import, preview inlining, or `~/.codex/pets` behavior in this phase.
|
||||
- Do not change installed pet runtime rendering; installed pets still use `spritesheet.webp` after install.
|
||||
- Do not remove or break `catalog.v2.json`; keep it available for older desktop clients.
|
||||
- Do not make the Electron renderer construct untrusted image URLs independently.
|
||||
- Do not broaden desktop CSP beyond the exact image origins needed.
|
||||
|
||||
## Problem
|
||||
|
||||
The current desktop Pet Manager reads `catalog.v2.json`, then uses each pet's `preview` URL as a card preview. Today `web/scripts/*` writes `preview` as the full spritesheet:
|
||||
|
||||
```js
|
||||
preview: `${PUBLIC_BASE_URL}${pet.spritesheetPath}`
|
||||
```
|
||||
|
||||
That means the picker can request hundreds or thousands of full files like:
|
||||
|
||||
```text
|
||||
https://openpets.dev/pets/<slug>/spritesheet.webp
|
||||
```
|
||||
|
||||
With a 1,000+ pet catalog, this creates excessive network, decode, memory, and layout work. Desktop-side lazy loading helps, but it does not solve the root issue: gallery cards need tiny thumbnails, not full runtime spritesheets.
|
||||
|
||||
## Desired outcome
|
||||
|
||||
- Public catalog supports 1,000+ pets without a huge single JSON payload.
|
||||
- Pet Manager initially loads only a small page of metadata and small thumbnails.
|
||||
- Full spritesheets are fetched only when needed for install/runtime or, optionally, selected-pet detail preview.
|
||||
- Pet Manager keeps existing filters, including `Codex`, and adds the same high-level public catalog filters already used on the web: `Western` and `Asian`.
|
||||
- Existing desktop clients can continue using `catalog.v2.json`.
|
||||
- Codex/local pet behavior remains unchanged.
|
||||
|
||||
## Proposed asset model
|
||||
|
||||
For every public catalog pet under `web/public/pets/<slug>/`, generate and publish:
|
||||
|
||||
```text
|
||||
spritesheet.webp # existing full runtime/install asset
|
||||
thumb.webp # new tiny static thumbnail for gallery cards
|
||||
preview.webp # optional small animated/detail preview, if cheap to generate
|
||||
<petId>.zip # existing install package, served from zip.openpets.dev
|
||||
```
|
||||
|
||||
Recommended budgets:
|
||||
|
||||
- `thumb.webp`: 96-160px static image, target < 10-20 KB.
|
||||
- `preview.webp`: optional short idle animation or selected-detail image, target < 50-100 KB.
|
||||
- `spritesheet.webp`: unchanged; used for installs/runtime, not bulk gallery cards.
|
||||
|
||||
If `preview.webp` is not generated in the first implementation, desktop can use `thumbnail` in cards and reserve `spritesheet` only for selected detail/install paths.
|
||||
|
||||
## Proposed catalog model
|
||||
|
||||
Keep `public/pets/catalog.v2.json` unchanged for compatibility.
|
||||
|
||||
Add `public/pets/catalog.v3.json` as an index:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"generatedAt": "2026-05-12T00:00:00.000Z",
|
||||
"total": 2500,
|
||||
"pageSize": 100,
|
||||
"filters": {
|
||||
"categories": [
|
||||
{ "id": "western", "label": "Western", "count": 1250 },
|
||||
{ "id": "asian", "label": "Asian", "count": 1250 }
|
||||
]
|
||||
},
|
||||
"pages": [
|
||||
"https://openpets.dev/pets/catalog.v3/page-000.json",
|
||||
"https://openpets.dev/pets/catalog.v3/page-001.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Add paginated page files under `public/pets/catalog.v3/`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 3,
|
||||
"page": 0,
|
||||
"pageSize": 100,
|
||||
"pets": [
|
||||
{
|
||||
"id": "snoopy",
|
||||
"displayName": "Snoopy",
|
||||
"description": "A tiny black-and-white beagle with a red collar for calm coding sessions.",
|
||||
"thumbnail": "https://openpets.dev/pets/snoopy-23e05847/thumb.webp",
|
||||
"preview": "https://openpets.dev/pets/snoopy-23e05847/preview.webp",
|
||||
"spritesheet": "https://openpets.dev/pets/snoopy-23e05847/spritesheet.webp",
|
||||
"zip": "https://zip.openpets.dev/pets/snoopy-23e05847/snoopy.zip",
|
||||
"category": "western",
|
||||
"subcategory": "cartoons"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `thumbnail`: required for v3 catalog pets.
|
||||
- `preview`: optional; desktop must tolerate missing/failed preview.
|
||||
- `spritesheet`: optional for gallery use, but useful for detail/runtime preview if explicitly selected.
|
||||
- `zip`: required for installation.
|
||||
- `category`: required for v3 public catalog pets and currently limited to `western` or `asian`.
|
||||
- `subcategory`: optional; preserve existing web metadata where present.
|
||||
|
||||
V3 index invariants:
|
||||
|
||||
- Maximum index response size: 256 KB.
|
||||
- Maximum page response size: 256 KB.
|
||||
- Default page size: 100 pets.
|
||||
- Maximum page size: 200 pets.
|
||||
- Maximum page count: 100 pages for this phase.
|
||||
- `total` must equal the sum of pets across all pages during generation.
|
||||
- Pet IDs must be unique across all pages.
|
||||
- Page URLs must match `https://openpets.dev/pets/catalog.v3/page-<3 digit>.json`.
|
||||
- Deploy ordering must publish page files before publishing the index that references them.
|
||||
|
||||
## Public catalog filters
|
||||
|
||||
The web app already groups public pets into two top-level filters: `Western` and `Asian`. V3 should carry this as canonical metadata so desktop does not infer categories from names, slugs, descriptions, or paths.
|
||||
|
||||
Filter contract:
|
||||
|
||||
- `category` is required for every v3 public catalog pet.
|
||||
- Allowed initial values are exactly:
|
||||
- `western`
|
||||
- `asian`
|
||||
- Desktop labels these as `Western` and `Asian`.
|
||||
- Desktop keeps existing filters and adds category filters: `All`, `Installed`, `Codex`, `Western`, and `Asian`.
|
||||
- `Codex` filter behavior must stay unchanged from the current Pet Manager.
|
||||
- If a v3 pet has a missing/unknown category, validation should reject the page or mark the pet unavailable rather than guessing.
|
||||
- v2 fallback does not provide reliable category filtering; when using v2 fallback, desktop should hide `Western`/`Asian` filters or show them disabled.
|
||||
- Codex/local-only pets are not part of this category filter contract in this phase. If shown in the same grid, they continue to appear under the existing `Codex` filter and under `All`/`Installed` when applicable, but not under `Western`/`Asian` unless they correspond to a catalog pet with v3 category metadata.
|
||||
|
||||
Category source rules:
|
||||
|
||||
- `web/scripts/import-reviewed-pets.js` already validates reviewed-pet `category`; v3 must preserve it.
|
||||
- `web/scripts/sync-pets.js` must preserve category from an existing manifest entry when present.
|
||||
- `web/scripts/sync-local-pets.js` must preserve category from the existing generated/manifest entry when present.
|
||||
- Any pet still missing `western`/`asian` after preservation is excluded from v3 and logged, while v2 remains unchanged for compatibility.
|
||||
- Generation must not infer category from names, slugs, descriptions, upstream source, or folder paths.
|
||||
|
||||
## Web implementation plan
|
||||
|
||||
Update all public catalog writers that currently emit `catalog.v2.json`:
|
||||
|
||||
- `web/scripts/sync-pets.js`
|
||||
- `web/scripts/import-reviewed-pets.js`
|
||||
- `web/scripts/sync-local-pets.js`
|
||||
|
||||
Tasks:
|
||||
|
||||
1. Add shared helpers for catalog asset paths and v3 output shape.
|
||||
2. Generate `thumb.webp` for each public catalog pet if missing or stale.
|
||||
3. Optionally generate `preview.webp` after `thumb.webp` is stable.
|
||||
4. Continue writing `catalog.v2.json` as a backward-compatible subset if the public catalog grows beyond the existing v2 desktop validation limit.
|
||||
5. Write `catalog.v3.json` plus `catalog.v3/page-XXX.json` files.
|
||||
6. Keep zip URLs on `zip.openpets.dev` unchanged.
|
||||
7. Include the existing web category metadata in every v3 pet as `category: "western" | "asian"`.
|
||||
8. Add validation that every v3 page item has safe `id`, `thumbnail`, `zip`, and known `category` fields.
|
||||
9. Add category counts to the v3 index so desktop can show correct filters before every page is loaded.
|
||||
|
||||
Thumbnail generation options:
|
||||
|
||||
- Preferred: use `sharp` in the web workspace to crop/extract the universal spritesheet idle first frame from the 8-column by 9-row spritesheet and resize to a small static WebP.
|
||||
- Fallback: if sprite-frame extraction is unreliable for a pet, create a small resized/cropped static thumbnail from the top-left/idle frame area and log the fallback.
|
||||
- Stale detection should compare `thumb.webp` mtime to `spritesheet.webp`; regenerate when the spritesheet is newer.
|
||||
- Generation should fail or loudly warn if thumbnails exceed the agreed byte/dimension budget.
|
||||
|
||||
## Desktop implementation plan
|
||||
|
||||
Add a new desktop catalog path while keeping v2 fallback:
|
||||
|
||||
1. Fetch `https://openpets.dev/pets/catalog.v3.json` first.
|
||||
2. Validate the index response size and exact final URL.
|
||||
3. Fetch the first page only on initial Pet Manager open.
|
||||
4. Load additional pages on scroll, explicit "Load more", or search pagination.
|
||||
5. Render card images from `thumbnail`, not `preview`/`spritesheet`.
|
||||
6. Preserve the existing `Codex` filter and add category filters: `All`, `Installed`, `Codex`, `Western`, and `Asian`.
|
||||
7. Apply `Western`/`Asian` filters using validated v3 `category` metadata only.
|
||||
8. Use `preview` only for selected-pet detail if available and cheap.
|
||||
9. Use `spritesheet` only for selected-pet detail preview if explicitly needed; never for every card.
|
||||
10. Fall back to v2 when v3 is unavailable.
|
||||
11. Keep all remote image URLs main-process validated before exposing them to preload.
|
||||
|
||||
Main-process data contract:
|
||||
|
||||
- Add a paged v3 catalog UI state instead of requiring preload to know remote page URLs.
|
||||
- Main process owns index/page fetch, validation, caching, and install lookup.
|
||||
- `installPet(id)` must be able to resolve validated v3 metadata for pets outside page 0 by using a main-process cache or by fetching the needed validated page/index data.
|
||||
- Installed catalog pets that are not in the first loaded page must still retain usable installed-state rows; detail/category/thumbnail can be enriched as pages load.
|
||||
|
||||
Renderer behavior:
|
||||
|
||||
- Do not render 1,000 cards at once; use paging or virtualization.
|
||||
- Start with explicit paging/"Load more"; virtualization can be added later if needed.
|
||||
- Limit concurrent remote image loads.
|
||||
- Use async decoding and no referrer.
|
||||
- Keep graceful empty/failure surfaces for thumbnail load errors.
|
||||
- If only a thumbnail is available for a catalog pet, do not show fake animated mini state previews. Either fetch a selected-detail preview/spritesheet on selection or hide/degrade mini state previews for that pet.
|
||||
- Do not change Codex/local pet rendering or import behavior in this phase.
|
||||
|
||||
## Security and compatibility notes
|
||||
|
||||
- Desktop CSP should continue to allow only `data:` and `https://openpets.dev` for Pet Manager images unless the implementation requires a narrower path/origin rule.
|
||||
- The renderer must not independently derive `thumbnail`, `preview`, `spritesheet`, or `zip` URLs.
|
||||
- Main process should validate:
|
||||
- catalog index final URL,
|
||||
- page URL origin/path,
|
||||
- thumbnail/preview/spritesheet origin/path/extension,
|
||||
- zip origin/path/extension.
|
||||
- `catalog.v2.json` remains the compatibility contract for currently shipped clients.
|
||||
- If the total public catalog exceeds the current v2 validator's limit, `catalog.v2.json` should remain capped to a compatible curated subset rather than silently breaking shipped clients.
|
||||
- `catalog.v3` can be rolled out on the web before desktop starts consuming it.
|
||||
|
||||
## Rollout plan
|
||||
|
||||
1. Web-only rollout:
|
||||
- Generate `thumb.webp` assets.
|
||||
- Publish `catalog.v3` alongside existing v2.
|
||||
- Verify URLs and asset sizes on production.
|
||||
2. Desktop fallback support:
|
||||
- Add v3 fetch/validation with v2 fallback.
|
||||
- Keep existing Pet Manager behavior if v3 is missing.
|
||||
3. Desktop performance update:
|
||||
- Switch cards to `thumbnail`.
|
||||
- Add paging/virtualization and bounded image loading.
|
||||
4. Cleanup/observability:
|
||||
- Add size checks to web sync scripts.
|
||||
- Add desktop contract tests for v3 validation and fallback.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `catalog.v2.json` output remains backward compatible.
|
||||
- `catalog.v3.json` and page files are generated by all relevant web catalog sync/import flows.
|
||||
- Every v3 pet has a small `thumbnail` URL under `https://openpets.dev/pets/` ending in `.webp`.
|
||||
- Desktop Pet Manager card grid uses `thumbnail` for catalog pets.
|
||||
- Desktop Pet Manager includes `All`, `Installed`, existing `Codex`, `Western`, and `Asian` filters when v3 metadata is available.
|
||||
- `Codex` filter behavior is unchanged.
|
||||
- `Western`/`Asian` filters are driven only by validated v3 `category` metadata.
|
||||
- If desktop falls back to v2, `Western`/`Asian` filters are hidden or disabled because v2 does not guarantee category metadata.
|
||||
- Opening Pet Manager with 1,000+ catalog pets does not request all full `spritesheet.webp` files.
|
||||
- Initial Pet Manager open requests only the v3 index, first page, and thumbnails for rendered/visible cards.
|
||||
- Full `spritesheet.webp` files are requested only for selected detail preview if explicitly implemented, install packages, or runtime installed pets.
|
||||
- v3 outage or validation failure falls back to v2 without breaking install/default/remove operations.
|
||||
- `catalog.v2.json` remains within the compatibility limit expected by shipped desktop clients.
|
||||
- Codex/local pet behavior is unchanged.
|
||||
- Web generation validates unique v3 IDs, category counts, page URLs, response-size budgets, and thumbnail existence.
|
||||
|
||||
## Test/check plan
|
||||
|
||||
Web:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun lint
|
||||
bun run build
|
||||
bun run sync:pets
|
||||
```
|
||||
|
||||
Desktop:
|
||||
|
||||
```bash
|
||||
pnpm --filter @open-pets/desktop build
|
||||
pnpm --filter @open-pets/desktop test
|
||||
pnpm package:desktop:dir
|
||||
```
|
||||
|
||||
Manual verification:
|
||||
|
||||
1. Publish or locally serve a v3 catalog with at least 1,000 pets.
|
||||
2. Open desktop Pet Manager.
|
||||
3. Confirm only index/page JSON and card thumbnails are loaded initially.
|
||||
4. Scroll/load more and confirm requests grow by page/viewport, not by total catalog size.
|
||||
5. Switch between `All`, `Installed`, `Codex`, `Western`, and `Asian`; confirm Codex remains unchanged and category filters match web categories.
|
||||
6. Select a pet and confirm detail still works.
|
||||
7. Install a pet and confirm runtime installed pet behavior is unchanged.
|
||||
8. Disable v3 and confirm v2 fallback works with category filters hidden/disabled.
|
||||
9. Confirm Codex/local pets behave exactly as before.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should `preview.webp` ship in the first implementation, or should v3 start with only `thumbnail` plus existing `spritesheet`?
|
||||
- Should desktop search initially search loaded pages only with explicit copy, or should the web publish a lightweight searchable index in the same phase?
|
||||
Loading…
Add table
Reference in a new issue