Prepare desktop catalog for store submission
This commit is contained in:
parent
8220d2259b
commit
cfac0b9161
11 changed files with 480 additions and 83 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue