Prepare desktop catalog for store submission

This commit is contained in:
Alvin Unreal 2026-05-27 14:22:06 +02:00
parent 8220d2259b
commit cfac0b9161
11 changed files with 480 additions and 83 deletions

View file

@ -21,3 +21,8 @@ When working on desktop UI, renderer, IPC, catalog, plugin, or pet-window behavi
Prefer concise, scoped logs that capture data shape, selected IDs, load/error states, and boundary decisions.
Route renderer diagnostics into the app log when possible so failures are visible in `openpets.log`, not only DevTools.
Avoid noisy permanent logs, secrets, full payload dumps, or logging in tight animation/render loops.
## Control Center CSP
When adding any renderer-visible URL scheme, image source, dev server endpoint, or internal protocol, update the Control Center CSP in both `apps/desktop/vite.config.ts` and `apps/desktop/src/renderer/index.html`.
Common pet image protocols include `openpets-codex:`, `openpets-installed:`, and `openpets-pet-preview:`; forgetting CSP causes images to load as the default/fallback pet even when install/render logic is correct.

View file

@ -28,7 +28,9 @@ const api = {
getCodexPets: () => ipcRenderer.invoke("openpets:get-codex-pets"),
setDefaultPet: (petId) => ipcRenderer.invoke("openpets:set-default-pet", petId),
installPet: (petId) => ipcRenderer.invoke("openpets:install-pet", petId),
installLocalPet: () => ipcRenderer.invoke("openpets:install-local-pet"),
importCodexPet: (petId) => ipcRenderer.invoke("openpets:import-codex-pet", petId),
openGallery: () => ipcRenderer.invoke("openpets:open-gallery"),
removePet: (petId) => ipcRenderer.invoke("openpets:remove-pet", petId),
onRouteChange: (callback) => {
const listener = (_event, route) => callback(route);

View file

@ -3,6 +3,24 @@ import { join } from "node:path";
import { app, nativeImage, type NativeImage } from "electron";
const trayIconRelativePath = join("assets", "tray-icon.png");
const appIconRelativePath = join("assets", "app-icon.png");
let cachedAppIcon: NativeImage | null = null;
export function createAppIcon(): NativeImage {
if (cachedAppIcon && !cachedAppIcon.isEmpty()) return cachedAppIcon;
const assetPath = join(app.getAppPath(), appIconRelativePath);
const assetImage = nativeImage.createFromPath(assetPath);
if (!assetImage.isEmpty()) {
cachedAppIcon = assetImage;
return cachedAppIcon;
}
console.error(`OpenPets app icon asset could not be loaded from ${assetPath}; using generated fallback icon.`);
cachedAppIcon = createFallbackTrayIcon();
return cachedAppIcon;
}
export function createTrayIcon(): NativeImage {
const assetPath = join(app.getAppPath(), trayIconRelativePath);

View file

@ -43,17 +43,17 @@ export async function getCatalogUiState(): Promise<CatalogUiState> {
const remoteV3 = await tryLoadRemoteCatalogV3Index();
if (remoteV3.ok) {
const firstPage = await tryLoadRemoteCatalogV3Page(0, remoteV3.index);
const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.index);
if (!firstPage.ok) return await getV2OrFixtureCatalogUiState(`v3 page unavailable: ${firstPage.error}`);
return {
source: "remote",
pets: firstPage.pets,
pets: filterSurfaceablePets(firstPage.pets),
generatedAt: remoteV3.index.generatedAt,
version: 3,
total: remoteV3.index.total,
total: surfaceableTotal(remoteV3.index),
categories: remoteV3.index.filters.categories,
page: 0,
pageCount: remoteV3.index.pages.length,
pageCount: surfaceablePageCount(remoteV3.index),
supportsCategories: true,
originalsCount: remoteV3.index.filters.originalsCount,
featuredCount: remoteV3.index.filters.featuredCount,
@ -67,19 +67,19 @@ 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", pets: [], error: remoteV3.error };
if (page >= remoteV3.index.pages.length) throw new Error("Catalog page is out of range.");
const pageResult = await tryLoadRemoteCatalogV3Page(page, remoteV3.index);
if (page >= surfaceablePageCount(remoteV3.index)) throw new Error("Catalog page is out of range.");
const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.index);
if (!pageResult.ok) return { source: "error", pets: [], error: pageResult.error };
return {
source: "remote",
pets: pageResult.pets,
pets: filterSurfaceablePets(pageResult.pets),
generatedAt: remoteV3.index.generatedAt,
version: 3,
total: remoteV3.index.total,
total: surfaceableTotal(remoteV3.index),
categories: remoteV3.index.filters.categories,
page,
pageCount: remoteV3.index.pages.length,
pageCount: surfaceablePageCount(remoteV3.index),
supportsCategories: true,
originalsCount: remoteV3.index.filters.originalsCount,
featuredCount: remoteV3.index.filters.featuredCount,
@ -91,8 +91,8 @@ export async function getCatalogSearchUiState(): Promise<CatalogSearchUiState> {
if (!remoteV3.ok) return { source: "error", pets: [], error: remoteV3.error };
try {
const pets = await getRemoteCatalogV3Search(remoteV3.index);
return { source: "remote", pets, total: remoteV3.index.total };
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index);
return { source: "remote", pets: surfacedPets, total: surfacedPets.length };
} catch (error) {
return { source: "error", pets: [], error: error instanceof Error ? error.message : "unknown error" };
}
@ -101,21 +101,27 @@ export async function getCatalogSearchUiState(): Promise<CatalogSearchUiState> {
export async function getCatalogPet(petId: string): Promise<CatalogPetV2> {
const remoteV3 = await tryLoadRemoteCatalogV3Index();
if (remoteV3.ok) {
let blockedHiddenV3Pet = false;
try {
const searchPets = await getRemoteCatalogV3Search(remoteV3.index);
const searchPet = searchPets.find((pet) => pet.id === petId);
if (searchPet && !isSurfaceablePet(searchPet)) {
blockedHiddenV3Pet = true;
throw new Error(`Pet is not available in the curated catalog: ${petId}`);
}
if (searchPet) {
const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.index);
const pet = page.find((candidate) => candidate.id === petId);
if (pet) return pet;
if (pet && isSurfaceablePet(pet)) return pet;
}
} catch {
} catch (error) {
if (blockedHiddenV3Pet) throw error;
// Fall through to v2/fixture so visible v2-compatible pets remain installable during partial v3 outages.
}
}
const catalog = await getV2CatalogOrFixture();
const pet = catalog.pets.find((candidate) => candidate.id === petId);
const pet = filterSurfaceablePets(catalog.pets).find((candidate) => candidate.id === petId);
if (!pet) throw new Error(`Pet is not available in the validated catalog: ${petId}`);
return pet;
}
@ -127,10 +133,10 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
if (remote.ok) {
return {
source: "remote",
pets: remote.catalog.pets,
pets: filterSurfaceablePets(remote.catalog.pets),
generatedAt: remote.catalog.generatedAt,
version: 2,
total: remote.catalog.pets.length,
total: filterSurfaceablePets(remote.catalog.pets).length,
supportsCategories: false,
};
}
@ -140,11 +146,11 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
if (fixture.ok) {
return {
source: "fixture",
pets: fixture.catalog.pets,
pets: filterSurfaceablePets(fixture.catalog.pets),
generatedAt: fixture.catalog.generatedAt,
error: `Catalog unavailable: ${remoteV3Error}; v2 unavailable: ${remote.error}`,
version: 2,
total: fixture.catalog.pets.length,
total: filterSurfaceablePets(fixture.catalog.pets).length,
supportsCategories: false,
};
}
@ -156,6 +162,29 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
};
}
function filterSurfaceablePets<T extends { readonly original?: boolean; readonly featured?: boolean }>(pets: readonly T[]): readonly T[] {
return pets.filter(isSurfaceablePet);
}
function isSurfaceablePet(pet: { readonly original?: boolean; readonly featured?: boolean }): boolean {
return pet.original === true || pet.featured === true;
}
function getSurfaceableSearchPets(pets: readonly CatalogV3SearchPet[], index: CatalogV3Index): readonly CatalogV3SearchPet[] {
return filterSurfaceablePets(pets).map((pet, surfaceIndex) => ({
...pet,
catalogPage: Math.floor(surfaceIndex / index.pageSize),
}));
}
function surfaceableTotal(index: CatalogV3Index): number {
return (index.filters.originalsCount ?? 0) + (index.filters.featuredCount ?? 0);
}
function surfaceablePageCount(index: CatalogV3Index): number {
return Math.ceil(surfaceableTotal(index) / index.pageSize);
}
async function tryLoadRemoteCatalogV3Index(): Promise<{ readonly ok: true; readonly index: CatalogV3Index } | { readonly ok: false; readonly error: string }> {
try {
const index = await getRemoteCatalogV3Index();
@ -173,6 +202,20 @@ async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index):
}
}
async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly pets: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
try {
const searchPets = filterSurfaceablePets(await getRemoteCatalogV3Search(index));
const pageSearchPets = searchPets.slice(page * index.pageSize, (page + 1) * index.pageSize);
const ids = new Set(pageSearchPets.map((pet) => pet.id));
const catalogPageNumbers = [...new Set(pageSearchPets.map((pet) => pet.catalogPage))];
const catalogPages = await Promise.all(catalogPageNumbers.map((catalogPage) => getRemoteCatalogV3Page(catalogPage, index)));
const petsById = new Map(catalogPages.flat().filter((pet) => ids.has(pet.id) && isSurfaceablePet(pet)).map((pet) => [pet.id, pet]));
return { ok: true, pets: pageSearchPets.map((pet) => petsById.get(pet.id)).filter((pet): pet is CatalogPetV2 => Boolean(pet)) };
} catch (error) {
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;

View file

@ -2,6 +2,7 @@ import { app } from "electron";
import { delimiter, resolve } from "node:path";
import { initializeAppState, releaseStartupInstallLock } from "./app-state.js";
import { createAppIcon } from "./assets.js";
import { installDefaultPetDisplayHandlers, shouldOpenDefaultPetOnLaunch, showDefaultPet } from "./default-pet-controller.js";
import { installAppLifecycle } from "./lifecycle.js";
import { debug, error as logError, getLogFilePath, info, initializeLogger, warn } from "./logger.js";
@ -38,9 +39,13 @@ if (!gotSingleInstanceLock) {
app.whenReady().then(async () => {
initializeLogger();
app.setName("OpenPets");
if (process.platform === "win32") {
app.setAppUserModelId("dev.openpets.app");
}
info("app", "startup begin", { version: app.getVersion(), platform: process.platform, arch: process.arch, packaged: app.isPackaged, pid: process.pid, ozonePlatform: app.commandLine.getSwitchValue("ozone-platform") || null });
if (process.platform === "darwin") {
app.dock?.setIcon(createAppIcon());
app.dock?.hide();
}

View file

@ -1,6 +1,6 @@
import { createWriteStream } from "node:fs";
import { mkdtemp, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { constants, createWriteStream } from "node:fs";
import { lstat, mkdtemp, mkdir, open, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { basename, join, resolve, sep } from "node:path";
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";
@ -9,6 +9,7 @@ import type { Entry, ZipFile } from "yauzl";
import { getAppStateSnapshot, installPetState, removePetState, setDefaultPet, type OpenPetsStateV1 } from "./app-state.js";
import { getCatalogPet } from "./catalog.js";
import { maxCodexPetJsonBytes, maxCodexSpritesheetBytes, validateCodexPetMetadata, type CodexPetMetadata } from "./codex-pets-core.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";
@ -40,7 +41,10 @@ export async function installPet(petId: string): Promise<OpenPetsStateV1> {
try {
assertInsideRoot(petsRoot, tempDir);
await extractPetZip(zip, tempDir);
await validateExtractedPet(tempDir);
const metadata = await validateExtractedPet(tempDir);
if (metadata.id !== petId || metadata.id !== catalogPet.id) {
throw new Error("Catalog pet package id does not match the requested pet.");
}
await rm(finalDir, { recursive: true, force: true });
await rename(tempDir, finalDir);
@ -66,6 +70,55 @@ export async function installPet(petId: string): Promise<OpenPetsStateV1> {
});
}
export async function installPetFromZipFile(zipPath: string): Promise<OpenPetsStateV1> {
return withPetOperation("local-import", async () => {
const zip = await readRegularFile(zipPath, maxZipDownloadBytes, "pet zip");
validateZipMagic(zip);
const petsRoot = getPetsRoot();
await mkdir(petsRoot, { recursive: true, mode: 0o700 });
const tempDir = await mkdtemp(join(petsRoot, ".local-import-"));
try {
assertInsideRoot(petsRoot, tempDir);
await extractPetZip(zip, tempDir);
const metadata = await validateExtractedPet(tempDir);
await finalizeLocalPetInstall(metadata, tempDir);
return installLocalPetState(metadata);
} catch (error) {
await rm(tempDir, { recursive: true, force: true });
throw error;
}
});
}
export async function installPetFromFolder(folderPath: string): Promise<OpenPetsStateV1> {
return withPetOperation("local-import", async () => {
const sourceDir = resolve(folderPath);
const sourceStats = await lstat(sourceDir);
if (sourceStats.isSymbolicLink()) throw new Error("Pet folder cannot be a symlink.");
if (!sourceStats.isDirectory()) throw new Error("Pet folder must be a directory.");
if (await realpath(sourceDir) !== sourceDir) throw new Error("Pet folder path is not canonical.");
const parsed = JSON.parse((await readRegularFile(join(sourceDir, "pet.json"), maxCodexPetJsonBytes, "pet.json")).toString("utf8")) as unknown;
const parsedId = isRecord(parsed) && typeof parsed.id === "string" ? parsed.id : basename(sourceDir);
const metadata = validateCodexPetMetadata(parsed, parsedId);
assertSafePetId(metadata.id);
if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === metadata.id)) throw new Error(`Pet is already installed: ${metadata.id}`);
const spritesheet = await readRegularFile(join(sourceDir, metadata.spritesheetPath), maxCodexSpritesheetBytes, "spritesheet.webp");
const petsRoot = getPetsRoot();
await mkdir(petsRoot, { recursive: true, mode: 0o700 });
const tempDir = await mkdtemp(join(petsRoot, `.local-import-${metadata.id}-`));
try {
assertInsideRoot(petsRoot, tempDir);
await writeFile(join(tempDir, "spritesheet.webp"), spritesheet, { mode: 0o600, flag: "wx" });
await writeFile(join(tempDir, "pet.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
await finalizeLocalPetInstall(metadata, tempDir);
return installLocalPetState(metadata);
} catch (error) {
await rm(tempDir, { recursive: true, force: true });
throw error;
}
});
}
export async function removePet(petId: string): Promise<OpenPetsStateV1> {
return withPetOperation(petId, async () => {
if (petId === builtInPet.id) {
@ -207,6 +260,7 @@ async function extractPetZip(zip: Buffer, tempDir: string): Promise<void> {
fileCount += 1;
if (fileCount > maxFiles) throw new Error("Zip contains too many files.");
if (safePath.relativeOutputPath === "pet.json" && entry.uncompressedSize > maxCodexPetJsonBytes) throw new Error("pet.json is too large.");
if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry is too large.");
extractedTotal += entry.uncompressedSize;
if (extractedTotal > maxExtractedTotalBytes) throw new Error("Zip extracted total is too large.");
@ -214,7 +268,7 @@ async function extractPetZip(zip: Buffer, tempDir: string): Promise<void> {
const outputPath = resolve(tempDir, safePath.relativeOutputPath);
assertOutputPathInside(tempDir, outputPath);
seenRequired.add(safePath.relativeOutputPath);
await writeEntry(entry, zipFile, outputPath, entry.uncompressedSize);
await writeEntry(entry, zipFile, outputPath, entry.uncompressedSize, safePath.relativeOutputPath === "pet.json" ? maxCodexPetJsonBytes : maxIndividualFileBytes);
};
zipFile.readEntry();
@ -274,7 +328,7 @@ function getUnixMode(entry: Entry): number | null {
return (entry.externalFileAttributes >> 16) & 0o177777;
}
function writeEntry(entry: Entry, zipFile: ZipFile, outputPath: string, expectedBytes: number): Promise<void> {
function writeEntry(entry: Entry, zipFile: ZipFile, outputPath: string, expectedBytes: number, maxBytes: number): Promise<void> {
return new Promise((resolvePromise, rejectPromise) => {
zipFile.openReadStream(entry, (error, readStream) => {
if (error) {
@ -291,7 +345,7 @@ function writeEntry(entry: Entry, zipFile: ZipFile, outputPath: string, expected
const counter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
actualBytes += chunk.byteLength;
if (actualBytes > maxIndividualFileBytes) {
if (actualBytes > maxBytes) {
callback(new Error("Zip entry exceeded individual size limit."));
return;
}
@ -313,16 +367,74 @@ function writeEntry(entry: Entry, zipFile: ZipFile, outputPath: string, expected
});
}
async function validateExtractedPet(tempDir: string): Promise<void> {
async function validateExtractedPet(tempDir: string): Promise<CodexPetMetadata> {
const petJsonPath = join(tempDir, "pet.json");
const spritesheetPath = join(tempDir, "spritesheet.webp");
assertOutputPathInside(tempDir, petJsonPath);
assertOutputPathInside(tempDir, spritesheetPath);
JSON.parse(await readFile(petJsonPath, "utf8")) as unknown;
const parsed = JSON.parse((await readRegularFile(petJsonPath, maxCodexPetJsonBytes, "pet.json")).toString("utf8")) as unknown;
const parsedId = isRecord(parsed) && typeof parsed.id === "string" ? parsed.id : basename(tempDir);
const metadata = validateCodexPetMetadata(parsed, parsedId);
assertSafePetId(metadata.id);
const spritesheet = await stat(spritesheetPath);
if (!spritesheet.isFile()) throw new Error("spritesheet.webp must be a file.");
if (spritesheet.size <= 0) throw new Error("spritesheet.webp is empty.");
if (spritesheet.size > maxIndividualFileBytes) throw new Error("spritesheet.webp is too large.");
return metadata;
}
async function finalizeLocalPetInstall(metadata: CodexPetMetadata, tempDir: string): Promise<void> {
if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === metadata.id)) throw new Error(`Pet is already installed: ${metadata.id}`);
const petsRoot = getPetsRoot();
const finalDir = getInstalledPetDir(metadata.id);
assertInsideRoot(petsRoot, finalDir);
await rm(finalDir, { recursive: true, force: true });
await rename(tempDir, finalDir);
try {
await validateInstalledRegularFile(join(finalDir, "spritesheet.webp"));
await validateInstalledRegularFile(join(finalDir, "pet.json"));
} catch (error) {
await rm(finalDir, { recursive: true, force: true });
throw error;
}
}
async function readRegularFile(path: string, maxBytes: number, label: string): Promise<Buffer> {
const resolved = resolve(path);
const stats = await lstat(resolved);
if (stats.isSymbolicLink()) throw new Error(`${label} cannot be a symlink.`);
if (!stats.isFile()) throw new Error(`${label} must be a file.`);
if (stats.size <= 0 || stats.size > maxBytes) throw new Error(`${label} size is invalid.`);
const file = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const openedStats = await file.stat();
if (!openedStats.isFile() || openedStats.size !== stats.size || openedStats.size <= 0 || openedStats.size > maxBytes) throw new Error(`${label} size is invalid.`);
return await file.readFile();
} finally {
await file.close();
}
}
async function validateInstalledRegularFile(path: string): Promise<void> {
const resolved = resolve(path);
const root = getPetsRoot();
if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) throw new Error("Installed pet file escapes pets root.");
const stats = await lstat(resolved);
if (stats.isSymbolicLink()) throw new Error("Imported pet file cannot be a symlink.");
if (!stats.isFile()) throw new Error("Imported pet file must be a regular file.");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
async function installLocalPetState(metadata: CodexPetMetadata): Promise<OpenPetsStateV1> {
try {
return installPetState({ id: metadata.id, displayName: metadata.displayName, description: metadata.description });
} catch (error) {
await rm(getInstalledPetDir(metadata.id), { recursive: true, force: true });
throw error;
}
}

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: https://openpets.dev openpets-codex: openpets-pet-preview:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: https://openpets.dev openpets-codex: openpets-installed: openpets-pet-preview:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-src 'none'" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenPets Control Center</title>
</head>

View file

@ -12,7 +12,7 @@ import vscodeLogoUrl from "../../../assets/integrations/vscode.svg";
import windsurfLogoUrl from "../../../assets/integrations/windsurf.svg";
import zedLogoUrl from "../../../assets/integrations/zed.svg";
type Filter = "all" | "installed" | "featured" | "originals" | "western" | "asian" | "codex";
type Filter = "all" | "installed" | "featured" | "originals" | "codex";
type InstalledPet = { id: string; displayName: string; description?: string; builtIn: boolean; protected: boolean; installed: boolean; broken?: boolean; brokenReason?: string; source?: { kind?: "catalog"; preview?: string } | { kind: "codex"; path: string } };
type PetEntry = { id: string; displayName: string; description?: string; searchText?: string; preview?: string; thumbnail?: string; spritesheet?: string; category?: "western" | "asian"; original?: boolean; featured?: boolean; catalogPage?: number; sourceKind?: "installed" | "catalog" | "codex"; installed?: boolean; builtIn?: boolean; protected?: boolean; broken?: boolean; brokenReason?: string };
type SearchPetEntry = Pick<PetEntry, "id" | "displayName" | "category" | "original" | "featured"> & { searchText?: string; catalogPage?: number };
@ -71,7 +71,9 @@ type ControlCenterApi = {
getCodexPets(): Promise<CodexState>;
setDefaultPet(petId: string): Promise<StateSnapshot>;
installPet(petId: string): Promise<unknown>;
installLocalPet(): Promise<unknown>;
importCodexPet(petId: string): Promise<unknown>;
openGallery(): Promise<void>;
removePet(petId: string): Promise<StateSnapshot>;
onRouteChange(callback: (route: Route) => void): () => void;
getIntegrationsState(selectedPetId?: string, commandMode?: "published" | "local" | "bundled"): Promise<AgentSetupSnapshot>;
@ -177,6 +179,13 @@ const ConfigureIcon = () => (
</svg>
);
const EyeIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
const FolderPlusIcon = () => (
<svg className="btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 10v6" />
@ -336,8 +345,15 @@ const PetsIcon = () => (
const SettingsIcon = () => (
<svg className="nav-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path fill="currentColor" d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0a2.34 2.34 0 0 0 3.319 1.915a2.34 2.34 0 0 1 2.33 4.033a2.34 2.34 0 0 0 0 3.831a2.34 2.34 0 0 1-2.33 4.033a2.34 2.34 0 0 0-3.319 1.915a2.34 2.34 0 0 1-4.659 0a2.34 2.34 0 0 0-3.32-1.915a2.34 2.34 0 0 1-2.33-4.033a2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle fill="currentColor" cx="12" cy="12" r="3" />
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>
);
@ -350,9 +366,12 @@ const PluginsIcon = () => (
const IntegrationsIcon = () => (
<svg className="nav-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path fill="currentColor" d="M17 19a1 1 0 0 1-1-1v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2a1 1 0 0 1-1 1zm0 2v-2" />
<path fill="currentColor" d="M19 14V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V10m16 11v-2M3 5V3" />
<path fill="currentColor" d="M4 10a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2a2 2 0 0 1-2 2zm3-5V3" />
<circle cx="6" cy="6" r="3" />
<circle cx="18" cy="6" r="3" />
<circle cx="12" cy="18" r="3" />
<path d="M8.6 7.5 10.8 15" />
<path d="M15.4 7.5 13.2 15" />
<path d="M9 6h6" />
</svg>
);
@ -424,8 +443,19 @@ function DashboardView({ onNavigate }: { onNavigate: (route: Route) => void }) {
const reactionEntries = Object.entries(activity.reactionCounts)
.filter(([, count]) => count > 0)
.sort(([, a], [, b]) => b - a);
const topReactionEntry = reactionEntries[0];
const topReaction = topReactionEntry ? topReactionEntry[0].charAt(0).toUpperCase() + topReactionEntry[0].slice(1) : "None yet";
const reactionTotal = reactionEntries.reduce((total, [, count]) => total + count, 0);
const reactionColors = ["#3b82f6", "#a855f7", "#f97316", "#14b8a6"];
const reactionDonutSegments = reactionEntries.slice(0, 4).map(([label, count], index) => ({
label,
count,
color: reactionColors[index] ?? "#64748b",
}));
const topCompanionEntries = Object.entries(activity.perPetActivityCounts)
.filter(([, count]) => count > 0)
.sort(([, a], [, b]) => b - a)
.slice(0, 4);
const maxCompanionActivity = Math.max(...topCompanionEntries.map(([, count]) => count), 1);
const lastActiveLabel = activity.lastActivityAt ? new Date(activity.lastActivityAt).toLocaleString() : "No activity yet";
const updateLabel = updateStatus.state === "available" ? "Update available" : updateStatus.state === "error" ? "Check failed" : updateStatus.state === "checking" ? "Checking" : updateStatus.state === "current" ? "Current" : "Not checked";
return (
@ -498,17 +528,63 @@ function DashboardView({ onNavigate }: { onNavigate: (route: Route) => void }) {
</div>
</div>
<div className="flex items-center justify-between p-4 rounded-2xl bg-blue-50/30 border border-blue-100/30">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-bold text-slatecopy uppercase tracking-wider">Last Interaction</span>
<span className="text-sm font-bold text-navy">
{activity.lastActivityAt ? new Date(activity.lastActivityAt).toLocaleString() : "Never"}
</span>
</div>
<div className="flex flex-col gap-1 text-right">
<span className="text-[10px] font-bold text-slatecopy uppercase tracking-wider">Top Reaction</span>
<span className="text-sm font-bold text-brand">{topReaction}</span>
</div>
<div className="dashboard-activity-charts">
<section className="dashboard-chart-panel dashboard-reaction-mix">
<div className="dashboard-chart-heading">
<span>Reaction Mix</span>
<small>{reactionTotal ? `${reactionTotal.toLocaleString()} total` : "Waiting for activity"}</small>
</div>
<div className="dashboard-donut-row">
<div className="dashboard-donut" aria-label="Reaction mix chart">
<svg viewBox="0 0 100 100" role="img">
<circle className="dashboard-donut-track" cx="50" cy="50" r="40" />
{reactionTotal > 0 && reactionDonutSegments.map((segment, index) => {
const circumference = 251.327;
const previousTotal = reactionDonutSegments.slice(0, index).reduce((total, item) => total + item.count, 0);
const dash = (segment.count / reactionTotal) * circumference;
const offset = -(previousTotal / reactionTotal) * circumference;
return <circle key={segment.label} className="dashboard-donut-segment" cx="50" cy="50" r="40" stroke={segment.color} strokeDasharray={`${dash} ${circumference - dash}`} strokeDashoffset={offset} />;
})}
</svg>
<div className="dashboard-donut-center">
<strong>{reactionTotal.toLocaleString()}</strong>
<span>reactions</span>
</div>
</div>
<div className="dashboard-donut-legend">
{reactionDonutSegments.length ? reactionDonutSegments.map((segment) => (
<div key={segment.label} className="dashboard-donut-legend-item">
<span className="dashboard-donut-dot" style={{ background: segment.color }} />
<span>{segment.label}</span>
<strong>{segment.count}</strong>
</div>
)) : <p>No reaction mix yet.</p>}
</div>
</div>
</section>
<section className="dashboard-chart-panel dashboard-companion-bars">
<div className="dashboard-chart-heading">
<span>Top Companions</span>
<small>Most active pets</small>
</div>
<div className="dashboard-bars-list">
{topCompanionEntries.length ? topCompanionEntries.map(([petId, count]) => {
const label = petId === defaultPet.id ? defaultPet.displayName : petId.replace(/[-_]/g, " ");
return (
<div key={petId} className="dashboard-bar-item">
<div className="dashboard-bar-labels">
<span>{label}</span>
<strong>{count}</strong>
</div>
<div className="dashboard-bar-track"><span style={{ width: `${Math.max(8, Math.round((count / maxCompanionActivity) * 100))}%` }} /></div>
</div>
);
}) : <p className="dashboard-empty-note">No companion activity yet.</p>}
</div>
</section>
<div className="dashboard-last-active-pill">Last active: <strong>{lastActiveLabel}</strong></div>
</div>
</div>
</GlassCard>
@ -589,8 +665,6 @@ const filterIcons: Record<Filter, React.ReactNode> = {
installed: <FilterInstalledIcon />,
featured: <FilterFeaturedIcon />,
originals: <FilterOriginalIcon />,
western: <FilterWesternIcon />,
asian: <FilterAsianIcon />,
codex: <FilterCodexIcon />,
};
@ -599,8 +673,6 @@ const filterLabels: Record<Filter, string> = {
installed: "Installed",
featured: "Featured",
originals: "Originals",
western: "Western",
asian: "Asian",
codex: "Codex",
};
@ -700,6 +772,10 @@ function isAllowedCodexPreview(value: string | undefined): value is string {
return typeof value === "string" && /^openpets-codex:\/\/spritesheet\/[a-zA-Z0-9%][a-zA-Z0-9%_-]{0,128}$/u.test(value);
}
function isAllowedInstalledPetPreview(value: string | undefined): value is string {
return typeof value === "string" && /^openpets-installed:\/\/spritesheet\/[a-zA-Z0-9%][a-zA-Z0-9%_-]{0,128}$/u.test(value);
}
function isAllowedDefaultPetPreview(value: string | undefined): value is string {
return typeof value === "string" && /^openpets-pet-preview:\/\/spritesheet\/default\?v=[a-z0-9_-]+-\d+-\d+$/u.test(value);
}
@ -709,7 +785,11 @@ function isAllowedDataUrl(value: string | undefined): value is string {
}
function safePetImage(value: string | undefined): string | undefined {
return isAllowedCatalogPreview(value) || isAllowedCodexPreview(value) || isAllowedDefaultPetPreview(value) || isAllowedDataUrl(value) ? value : undefined;
return isAllowedCatalogPreview(value) || isAllowedCodexPreview(value) || isAllowedInstalledPetPreview(value) || isAllowedDefaultPetPreview(value) || isAllowedDataUrl(value) ? value : undefined;
}
function installedPetSpritesheetUrl(petId: string): string {
return `openpets-installed://spritesheet/${encodeURIComponent(petId)}`;
}
function imageDebug(value: string | undefined): string {
@ -1949,8 +2029,9 @@ function App() {
const rows: PetEntry[] = (state?.pets.installed ?? []).map((p) => {
const catalogPet = catalogMap.get(p.id);
const codexPet = codexMap.get(p.id);
const spritesheet = safePetImage(codexPet?.spritesheet) || safePetImage(catalogPet?.spritesheet);
const preview = safePetImage(codexPet?.preview) || safePetImage(catalogPet?.preview) || safePetImage(catalogPet?.thumbnail) || safePetImage(p.source && "preview" in p.source ? (p.source as { preview?: string }).preview : undefined) || defaultThumbUrl;
const localSpritesheet = p.id && !catalogPet && !codexPet && !p.builtIn ? installedPetSpritesheetUrl(p.id) : undefined;
const spritesheet = safePetImage(codexPet?.spritesheet) || safePetImage(catalogPet?.spritesheet) || safePetImage(localSpritesheet);
const preview = safePetImage(codexPet?.preview) || safePetImage(catalogPet?.preview) || safePetImage(catalogPet?.thumbnail) || safePetImage(p.source && "preview" in p.source ? (p.source as { preview?: string }).preview : undefined) || safePetImage(localSpritesheet) || defaultThumbUrl;
const category = catalogPet?.category;
const original = catalogPet?.original;
const featured = catalogPet?.featured;
@ -1995,7 +2076,6 @@ function App() {
if (filter === "codex" && p.sourceKind !== "codex" && !(installed.get(p.id)?.source?.kind === "codex")) return false;
if (filter === "originals" && !p.original && !p.builtIn) return false;
if (filter === "featured" && (!p.featured || p.original)) return false;
if ((filter === "western" || filter === "asian") && (p.category !== filter || p.featured || p.original)) return false;
const q = query.trim().toLowerCase();
return !q || `${p.displayName} ${p.description ?? ""} ${p.searchText ?? ""} ${p.id}`.toLowerCase().includes(q);
});
@ -2085,7 +2165,7 @@ function App() {
if (currentRoute !== "pets") return;
if (!catalogSearch) return;
const q = query.trim().toLowerCase();
const needsRemotePages = !!q || filter === "featured" || filter === "originals" || filter === "western" || filter === "asian";
const needsRemotePages = !!q || filter === "featured" || filter === "originals";
const pages = new Set<number>();
@ -2101,7 +2181,6 @@ function App() {
if (needsRemotePages) {
for (const pet of catalogSearch) {
if (pages.size >= 12) break;
if ((filter === "western" || filter === "asian") && (pet.category !== filter || pet.featured || pet.original)) continue;
if (filter === "originals" && !pet.original) continue;
if (filter === "featured" && (!pet.featured || pet.original)) continue;
if (q && !`${pet.displayName} ${pet.searchText ?? ""} ${pet.id}`.toLowerCase().includes(q)) continue;
@ -2176,18 +2255,24 @@ function App() {
<div className="layout">
<GlassCard className="gallery">
<div className="toolbar"><SearchInput value={query} onChange={(e) => setQuery(e.target.value)} /></div>
<div className="filters">
{(["all", "installed", "featured", "originals", "western", "asian", "codex"] as Filter[]).map((f) => (
<button
key={f}
className={`filter ${filter === f ? "active" : ""} ${f === "originals" ? "original" : ""} ${f === "featured" ? "featured" : ""}`}
onClick={() => setFilter(f)}
aria-current={filter === f ? "page" : undefined}
>
<span className="filter-icon-wrapper">{filterIcons[f]}</span>
<span className="filter-text">{filterLabels[f]}</span>
</button>
))}
<div className="filter-row">
<div className="filters">
{(["all", "installed", "featured", "originals", "codex"] as Filter[]).map((f) => (
<button
key={f}
className={`filter ${filter === f ? "active" : ""} ${f === "originals" ? "original" : ""} ${f === "featured" ? "featured" : ""}`}
onClick={() => setFilter(f)}
aria-current={filter === f ? "page" : undefined}
>
<span className="filter-icon-wrapper">{filterIcons[f]}</span>
<span className="filter-text">{filterLabels[f]}</span>
</button>
))}
</div>
<div className="filter-actions">
<Button variant="secondary" size="compact" icon={<FolderPlusIcon />} disabled={!!busy} onClick={() => void act("Importing", () => api.installLocalPet())}>Import pet</Button>
<Button variant="secondary" size="compact" icon={<HeartIcon />} onClick={() => void api.openGallery().catch((err) => setError(String(err?.message ?? err)))}>Gallery</Button>
</div>
</div>
<div className="pets-grid">{pets.map((pet) => {
const isBuiltIn = pet.builtIn;
@ -2216,17 +2301,17 @@ function App() {
<b className="card-title">{pet.displayName}</b>
</span>
<p className="card-desc">{pet.description || pet.id}</p>
<div className="badges">{isDefault && <StatusPill tone="green">Default</StatusPill>}{pet.original || pet.builtIn ? <StatusPill tone="yellow">Original</StatusPill> : pet.featured ? <StatusPill tone="purple">Featured</StatusPill> : null}{pet.category === "western" && !pet.original && !pet.featured && <StatusPill tone="slate">Western</StatusPill>}{pet.category === "asian" && !pet.original && !pet.featured && <StatusPill tone="slate">Asian</StatusPill>}{pet.installed && <StatusPill>Installed</StatusPill>}{pet.sourceKind === "codex" && <StatusPill tone="orange">Codex</StatusPill>}</div>
<div className="badges">{isDefault && <StatusPill tone="green">Default</StatusPill>}{pet.original || pet.builtIn ? <StatusPill tone="yellow">Original</StatusPill> : pet.featured ? <StatusPill tone="purple">Featured</StatusPill> : null}{pet.installed && <StatusPill>Installed</StatusPill>}{pet.sourceKind === "codex" && <StatusPill tone="orange">Codex</StatusPill>}</div>
<div className="pet-card-actions" onClick={(event) => event.stopPropagation()}>
<Button
variant="secondary"
size="compact"
icon={<ConfigureIcon />}
ariaLabel={`View ${pet.displayName} details`}
icon={<EyeIcon />}
ariaLabel={`View ${pet.displayName}`}
onClick={() => setSelectedId(pet.id)}
>
Details
View pet
</Button>
{canInstall && (
<Button
@ -2344,8 +2429,6 @@ function App() {
{selected.builtIn && <StatusPill tone="orange">Originals</StatusPill>}
{selected.original && !selected.builtIn && <StatusPill tone="yellow">Original</StatusPill>}
{selected.featured && !selected.original && <StatusPill tone="purple">Featured</StatusPill>}
{selected.category === "western" && !selected.original && !selected.featured && <StatusPill tone="slate">Western</StatusPill>}
{selected.category === "asian" && !selected.original && !selected.featured && <StatusPill tone="slate">Asian</StatusPill>}
</div>
{statusText && <p className="text-sm text-slatecopy mt-3 mb-0 font-medium">{statusText}</p>}
</div>
@ -2354,6 +2437,7 @@ function App() {
<h3 className="text-xs font-bold uppercase tracking-wider text-slatecopy mb-3">Preview Animations</h3>
<div className="pet-preview-grid">
{[
{ label: "Idle", state: "idle" as const },
{ label: "Thinking", state: "thinking" as const },
{ label: "Happy", state: "happy" as const },
{ label: "Wave", state: "wave" as const },

View file

@ -22,7 +22,10 @@
.glass { @apply rounded-[28px] border border-[rgba(126,161,210,.48)] p-5 shadow-glass backdrop-blur-xl; background: rgba(255, 255, 255, 0.76); box-shadow: inset 0 1px 0 rgba(255,255,255,.96), 0 24px 60px rgba(61,99,160,.15); }
.gallery { @apply flex min-h-0 flex-col overflow-hidden; }
.toolbar { @apply flex items-center gap-3; } .search { @apply min-w-0 flex-1 rounded-2xl border border-blue-300/80 bg-white/80 px-4 py-3 text-navy outline-none shadow-inner transition-shadow duration-150 focus:border-brand focus:bg-white focus:ring-4 focus:ring-brand/15; }
.filters { @apply my-3 flex flex-wrap gap-1.5; }
.filter-row { @apply my-3 flex flex-wrap items-center justify-between gap-2; }
.filters { @apply flex flex-wrap gap-1.5; }
.filter-actions { @apply ml-auto flex flex-wrap items-center justify-end gap-1.5; }
.filter-actions .btn { @apply h-8 min-h-8 rounded-xl px-2.5 py-1 text-xs; }
.filter { @apply rounded-xl border px-3 py-1.5 font-monoDisplay text-xs font-black uppercase tracking-wide text-slate-600 active:scale-[0.96] transition-[transform,background-color,border-color,box-shadow,translate] duration-150 flex items-center gap-1.5 cursor-pointer select-none h-8 shadow-sm; border-color: rgba(126, 161, 210, 0.4); background: linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 2px 4px rgba(61,99,160,0.06); }
.filter:hover:not(.active) { @apply text-brand; border-color: rgba(37, 99, 235, 0.35); background: linear-gradient(180deg, rgba(255, 255, 255, 1) 0%, rgba(239, 246, 255, 0.9) 100%); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 2px 4px rgba(61,99,160,0.06); }
.filter:active { translate: 0 0; }
@ -249,6 +252,33 @@
.dashboard-reaction-item { @apply flex items-center gap-2.5 rounded-2xl border border-blue-50 bg-white/60 px-3.5 py-2.5 shadow-sm transition-[border-color,background-color,box-shadow] duration-150 hover:border-blue-100 hover:bg-white; }
.dashboard-reaction-count { @apply font-mono text-sm font-bold text-navy tabular-nums; }
.dashboard-reaction-label { @apply text-xs font-medium text-slatecopy; }
.dashboard-activity-charts { @apply grid grid-cols-2 gap-3 rounded-[26px] border border-blue-100/50 bg-gradient-to-br from-white/70 to-blue-50/45 p-3 shadow-inner; }
.dashboard-chart-panel { @apply rounded-[22px] border border-white/80 bg-white/70 p-4 shadow-sm; }
.dashboard-chart-heading { @apply mb-3 flex items-start justify-between gap-3; }
.dashboard-chart-heading span { @apply font-monoDisplay text-[11px] font-black uppercase tracking-wider text-navy; }
.dashboard-chart-heading small { @apply text-right text-[10px] font-bold uppercase tracking-wide text-slatecopy/70; }
.dashboard-donut-row { @apply flex items-center gap-4; }
.dashboard-donut { @apply relative grid h-28 w-28 shrink-0 place-items-center; }
.dashboard-donut svg { @apply h-28 w-28 -rotate-90 overflow-visible; }
.dashboard-donut-track { @apply fill-none stroke-blue-100/70; stroke-width: 12; }
.dashboard-donut-segment { @apply fill-none transition-all duration-300; stroke-width: 12; stroke-linecap: butt; }
.dashboard-donut-center { @apply absolute inset-0 flex flex-col items-center justify-center rounded-full text-center; }
.dashboard-donut-center strong { @apply font-monoDisplay text-2xl font-black leading-none text-navy tabular-nums; }
.dashboard-donut-center span { @apply mt-1 text-[10px] font-bold uppercase tracking-wider text-slatecopy/70; }
.dashboard-donut-legend { @apply flex min-w-0 flex-1 flex-col gap-2; }
.dashboard-donut-legend p, .dashboard-empty-note { @apply m-0 text-xs font-semibold text-slatecopy/70; }
.dashboard-donut-legend-item { @apply grid grid-cols-[auto_1fr_auto] items-center gap-2 text-xs font-bold text-slatecopy; }
.dashboard-donut-legend-item strong { @apply font-mono text-navy tabular-nums; }
.dashboard-donut-dot { @apply h-2.5 w-2.5 rounded-full shadow-sm; }
.dashboard-bars-list { @apply flex flex-col gap-3; }
.dashboard-bar-item { @apply flex flex-col gap-1.5; }
.dashboard-bar-labels { @apply flex items-center justify-between gap-3 text-xs; }
.dashboard-bar-labels span { @apply truncate font-bold capitalize text-navy; }
.dashboard-bar-labels strong { @apply font-mono text-slatecopy tabular-nums; }
.dashboard-bar-track { @apply h-2.5 overflow-hidden rounded-full bg-blue-100/70 shadow-inner; }
.dashboard-bar-track span { @apply block h-full rounded-full bg-gradient-to-r from-blue-500 to-cyan-400 shadow-sm; }
.dashboard-last-active-pill { @apply col-span-2 inline-flex w-fit items-center gap-1 rounded-full border border-blue-100/70 bg-white/70 px-3 py-1.5 text-[11px] font-semibold text-slatecopy shadow-sm; }
.dashboard-last-active-pill strong { @apply font-bold text-navy; }
.dashboard-system-card { @apply flex flex-col gap-4; }
.dashboard-system-list { @apply flex flex-col gap-2.5; }
@ -261,6 +291,8 @@
.dashboard-layout { @apply overflow-visible pr-0; }
.dashboard-grid { @apply grid-cols-2; }
.dashboard-row { @apply grid-cols-1; }
.dashboard-activity-charts { @apply grid-cols-1; }
.dashboard-last-active-pill { @apply col-span-1; }
.dashboard-hero { @apply flex-col items-center text-center gap-6 p-6; }
.dashboard-hero-content { @apply items-center; }
.dashboard-hero-desc { @apply max-w-none; }

View file

@ -1,16 +1,17 @@
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { app, BrowserWindow, ipcMain, protocol, type IpcMainInvokeEvent } from "electron";
import { app, BrowserWindow, dialog, ipcMain, protocol, shell, type IpcMainInvokeEvent, type OpenDialogOptions } from "electron";
import { getAgentSetupSnapshot, runAgentSetupAction, updateAgentSetupCommandPaths } from "./agent-setup.js";
import { refreshAgentPetContent } from "./agent-pet-controller.js";
import { getAppStateSnapshot, normalizePetScale, petScaleOptions, updatePreferences } from "./app-state.js";
import { createAppIcon } from "./assets.js";
import { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js";
import { getCodexPetsUiState, importCodexPet, readCodexPetSpritesheet } from "./codex-pets.js";
import { recoverDefaultPetMouseInterop, refreshDefaultPetContent, resetDefaultPetToInitialPosition } from "./default-pet-controller.js";
import { installPet, removePet, setDefaultInstalledPet } from "./pet-installation.js";
import { getInstalledPetDir } from "./pet-paths.js";
import { installPet, installPetFromFolder, installPetFromZipFile, removePet, setDefaultInstalledPet } from "./pet-installation.js";
import { assertSafePetId, getInstalledPetDir } from "./pet-paths.js";
import { debug, error as logError, warn } from "./logger.js";
import { getPluginService, type PluginServiceResult } from "./plugin-service.js";
import { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata, validateReactionAnimationOverrides } from "./reaction-animation-mapping.js";
@ -23,6 +24,9 @@ const controlCenterRoutes = new Set<ControlCenterRoute>(["dashboard", "pets", "s
let controlCenterWindow: BrowserWindow | null = null;
let internalUiHandlersInstalled = false;
let pendingControlCenterRoute: ControlCenterRoute | null = null;
let pendingDockTimer: NodeJS.Timeout | null = null;
let lastDockHideAt = 0;
const dockHideShowCooldownMs = 1100;
function hasOpenInternalUiWindows(): boolean {
if (controlCenterWindow && !controlCenterWindow.isDestroyed()) return true;
@ -33,8 +37,24 @@ function syncDockVisibilityForInternalUi(): void {
if (process.platform !== "darwin") return;
const dock = app.dock;
if (!dock) return;
if (hasOpenInternalUiWindows()) dock.show();
else dock.hide();
if (pendingDockTimer) {
clearTimeout(pendingDockTimer);
pendingDockTimer = null;
}
if (hasOpenInternalUiWindows()) {
const elapsedSinceHide = Date.now() - lastDockHideAt;
const delayMs = elapsedSinceHide < dockHideShowCooldownMs ? dockHideShowCooldownMs - elapsedSinceHide : 0;
pendingDockTimer = setTimeout(() => {
pendingDockTimer = null;
dock.setIcon(createAppIcon());
dock.show();
}, delayMs);
} else {
dock.hide();
lastDockHideAt = Date.now();
}
}
function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; pets: ReturnType<typeof getAppStateSnapshot>["pets"] } {
@ -273,6 +293,41 @@ export function installInternalUiHandlers(): void {
return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getPetsStateSnapshot() : state;
});
ipcMain.handle("openpets:install-local-pet", async (event) => {
assertAllowedSender(event, ["control-center"]);
const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined;
const importKind = await chooseLocalPetImportKind(owner);
if (!importKind) return getPetsStateSnapshot();
const options: OpenDialogOptions = importKind === "zip" ? {
title: "Install pet from ZIP",
buttonLabel: "Install Pet",
properties: ["openFile"],
filters: [{ name: "OpenPets ZIP", extensions: ["zip"] }],
} : {
title: "Install pet from folder",
buttonLabel: "Install Pet",
properties: ["openDirectory"],
};
const result = owner ? await dialog.showOpenDialog(owner, options) : await dialog.showOpenDialog(options);
if (result.canceled || !result.filePaths[0]) return getPetsStateSnapshot();
const selectedPath = result.filePaths[0];
try {
const selectedStats = await stat(selectedPath);
const state = selectedStats.isDirectory() ? await installPetFromFolder(selectedPath) : await installPetFromZipFile(selectedPath);
debug("ui", "local pet import succeeded", { kind: selectedStats.isDirectory() ? "folder" : "zip" });
refreshDefaultPetContent();
return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getPetsStateSnapshot() : state;
} catch (error) {
logError("ui", "local pet import failed", { error: error instanceof Error ? error.message : String(error) });
throw error;
}
});
ipcMain.handle("openpets:open-gallery", async (event) => {
assertAllowedSender(event, ["control-center"]);
await shell.openExternal("https://openpets.dev/gallery");
});
ipcMain.handle("openpets:import-codex-pet", async (event, petId: unknown) => {
assertAllowedSender(event, ["control-center"]);
if (typeof petId !== "string") {
@ -320,6 +375,23 @@ export function installInternalUiHandlers(): void {
});
}
async function chooseLocalPetImportKind(owner: BrowserWindow | undefined): Promise<"zip" | "folder" | null> {
const options = {
type: "question" as const,
title: "Install pet",
message: "Install pet from ZIP or folder?",
detail: "Choose the source type before selecting the pet package.",
buttons: ["ZIP", "Folder", "Cancel"],
defaultId: 0,
cancelId: 2,
noLink: true,
};
const result = owner ? await dialog.showMessageBox(owner, options) : await dialog.showMessageBox(options);
if (result.response === 0) return "zip";
if (result.response === 1) return "folder";
return null;
}
export function installInternalUiProtocol(): void {
protocol.handle("openpets-codex", async (request) => {
try {
@ -339,6 +411,29 @@ export function installInternalUiProtocol(): void {
}
});
protocol.handle("openpets-installed", async (request) => {
try {
if (request.method !== "GET" && request.method !== "HEAD") return new Response(null, { status: 405 });
const url = new URL(request.url);
if (url.hostname !== "spritesheet" || url.search || url.hash) return new Response(null, { status: 404 });
const petId = decodeURIComponent(url.pathname.replace(/^\//, ""));
assertSafePetId(petId);
const pet = getAppStateSnapshot().pets.installed.find((candidate) => candidate.id === petId && !candidate.broken);
if (!pet) return new Response(null, { status: 404 });
const spritesheetPath = join(getInstalledPetDir(petId), "spritesheet.webp");
const spritesheet = await stat(spritesheetPath);
if (!spritesheet.isFile() || spritesheet.size <= 0 || spritesheet.size > 100 * 1024 * 1024) return new Response(null, { status: 404 });
return new Response(await readFile(spritesheetPath), {
headers: {
"Content-Type": "image/webp",
"Cache-Control": "private, max-age=60",
},
});
} catch {
return new Response(null, { status: 404 });
}
});
protocol.handle("openpets-pet-preview", async (request) => {
try {
if (request.method !== "GET" && request.method !== "HEAD") return new Response(null, { status: 405 });
@ -379,6 +474,7 @@ export function openControlCenterWindow(route: ControlCenterRoute = "dashboard")
minWidth: 820,
minHeight: 620,
show: false,
icon: createAppIcon(),
backgroundColor: "#f8fbff",
webPreferences: {
nodeIntegration: false,

View file

@ -1,8 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const productionCsp = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: https://openpets.dev openpets-codex: openpets-pet-preview:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-src 'none'";
const devCsp = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://openpets.dev openpets-codex: openpets-pet-preview:; connect-src 'self' http://127.0.0.1:5173 ws://127.0.0.1:5173; base-uri 'none'; form-action 'none'; frame-src 'none'";
const productionCsp = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: https://openpets.dev openpets-codex: openpets-installed: openpets-pet-preview:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-src 'none'";
const devCsp = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://openpets.dev openpets-codex: openpets-installed: openpets-pet-preview:; connect-src 'self' http://127.0.0.1:5173 ws://127.0.0.1:5173; base-uri 'none'; form-action 'none'; frame-src 'none'";
export default defineConfig(({ command }) => ({
root: "src/renderer",