Add control center settings page

This commit is contained in:
Alvin Unreal 2026-05-23 23:04:25 +02:00
parent dd7ee24e73
commit 3c119e209d
6 changed files with 351 additions and 18 deletions

View file

@ -2,6 +2,15 @@ const { contextBridge, ipcRenderer } = require("electron");
const api = {
getPetsState: () => ipcRenderer.invoke("openpets:get-pets-state"),
getSettingsState: () => ipcRenderer.invoke("openpets:get-settings-state"),
updatePreferences: (patch) => ipcRenderer.invoke("openpets:update-preferences", patch),
getReactionAnimationSettings: () => ipcRenderer.invoke("openpets:get-reaction-animation-settings"),
getLaunchAtLogin: () => ipcRenderer.invoke("openpets:get-launch-at-login"),
setLaunchAtLogin: (enabled) => ipcRenderer.invoke("openpets:set-launch-at-login", enabled),
getUpdateStatus: () => ipcRenderer.invoke("openpets:get-update-status"),
checkForUpdates: () => ipcRenderer.invoke("openpets:check-for-updates"),
openUpdateReleasePage: () => ipcRenderer.invoke("openpets:open-update-release-page"),
resetDefaultPetPosition: () => ipcRenderer.invoke("openpets:reset-default-pet-position"),
getCatalog: () => ipcRenderer.invoke("openpets:get-catalog"),
getCatalogPage: (page) => ipcRenderer.invoke("openpets:get-catalog-page", page),
getCatalogSearch: () => ipcRenderer.invoke("openpets:get-catalog-search"),

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:; 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-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

@ -1,10 +1,9 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
import openPetsLogoUrl from "../../../assets/openpets.webp";
import defaultThumbUrl from "../../../assets/default-pet-thumbnail.png";
const api = (window as unknown as { openPetsControlCenter: { getPetsState(): Promise<StateSnapshot>; getCatalog(): Promise<CatalogState>; getCatalogPage(page: number): Promise<CatalogState>; getCatalogSearch(): Promise<{ pets: SearchPetEntry[]; error?: string }>; getCodexPets(): Promise<CodexState>; setDefaultPet(petId: string): Promise<StateSnapshot>; installPet(petId: string): Promise<unknown>; importCodexPet(petId: string): Promise<unknown>; removePet(petId: string): Promise<StateSnapshot> } }).openPetsControlCenter;
type Filter = "all" | "installed" | "featured" | "originals" | "western" | "asian" | "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 };
@ -12,6 +11,35 @@ type SearchPetEntry = Pick<PetEntry, "id" | "displayName" | "category" | "origin
type StateSnapshot = { preferences: { defaultPetId: string }; pets: { installed: InstalledPet[] } };
type CatalogState = { pets: PetEntry[]; source: string; error?: string; page?: number; pageCount?: number; total?: number; categories?: { id: "western" | "asian"; label: string; count: number }[]; originalsCount?: number; featuredCount?: number };
type CodexState = { pets: PetEntry[]; error?: string };
type PetScaleOption = { label: string; value: number };
type UserSelectableAnimationState = "idle" | "review" | "running" | "waiting" | "waving" | "jumping" | "failed";
type ReactionAnimationOverrides = Record<string, UserSelectableAnimationState>;
type SettingsState = { preferences: { openDefaultPetOnLaunch: boolean; petScale: number; reactionAnimationOverrides?: ReactionAnimationOverrides }; petScaleOptions: PetScaleOption[] };
type LaunchAtLoginState = { supported: boolean; enabled: boolean };
type UpdateStatus = { state: "idle" | "checking" | "available" | "current" | "error"; currentVersion: string; latestVersion?: string; releaseUrl?: string; checkedAt?: number; error?: string };
type ReactionAnimationSettings = { reactions: { id: string; label: string; description: string; defaultAnimation: UserSelectableAnimationState }[]; animations: { id: UserSelectableAnimationState; label: string; description: string }[]; sprite: { frameWidth: number; frameHeight: number; columns: number; rows: number; states: Record<UserSelectableAnimationState, { row: number; frames: number; durationMs: number; iterations?: number | "infinite" }> }; overrides: ReactionAnimationOverrides; previewSpriteUrl: string };
type ControlCenterApi = {
getPetsState(): Promise<StateSnapshot>;
getSettingsState(): Promise<SettingsState>;
updatePreferences(patch: Partial<SettingsState["preferences"]>): Promise<SettingsState>;
getReactionAnimationSettings(): Promise<ReactionAnimationSettings>;
getLaunchAtLogin(): Promise<LaunchAtLoginState>;
setLaunchAtLogin(enabled: boolean): Promise<LaunchAtLoginState>;
getUpdateStatus(): Promise<UpdateStatus>;
checkForUpdates(): Promise<UpdateStatus>;
openUpdateReleasePage(): Promise<void>;
resetDefaultPetPosition(): Promise<SettingsState>;
getCatalog(): Promise<CatalogState>;
getCatalogPage(page: number): Promise<CatalogState>;
getCatalogSearch(): Promise<{ pets: SearchPetEntry[]; error?: string }>;
getCodexPets(): Promise<CodexState>;
setDefaultPet(petId: string): Promise<StateSnapshot>;
installPet(petId: string): Promise<unknown>;
importCodexPet(petId: string): Promise<unknown>;
removePet(petId: string): Promise<StateSnapshot>;
};
const api = (window as unknown as { openPetsControlCenter: ControlCenterApi }).openPetsControlCenter;
// Inline SVG Icons for actions, pagination, and filters
@ -387,6 +415,231 @@ function PetImage({ src, alt = "", debugLabel }: { src?: string; alt?: string; d
return <img src={safeSrc} alt={alt} draggable="false" onError={() => logPetsError("image-failed", { label: debugLabel, src: imageDebug(safeSrc) })} />;
}
function ToggleRow({ title, description, checked, disabled, onChange }: { title: string; description: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }) {
return <label className={`settings-row ${disabled ? "opacity-60" : ""}`}>
<div className="settings-row-info"><strong>{title}</strong><small>{description}</small></div>
<input className="settings-toggle" type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
</label>;
}
function formatUpdateStatus(status: UpdateStatus | null): string {
if (!status) return "Update status has not loaded yet.";
if (status.state === "checking") return "Checking for updates…";
if (status.state === "available") return `Version ${status.latestVersion ?? "latest"} is available.`;
if (status.state === "current") return `Up to date.`;
if (status.state === "error") return status.error || "Update check failed.";
return `Version: ${status.currentVersion}.`;
}
function ReactionPreviewSprite({ settings, state }: { settings: ReactionAnimationSettings; state: UserSelectableAnimationState }) {
const frame = { width: settings.sprite.frameWidth, height: settings.sprite.frameHeight };
const sprite = settings.sprite.states[state] ?? settings.sprite.states.idle;
const xValues = Array.from({ length: sprite.frames }, (_, index) => String(-index * frame.width)).join(";");
const y = -sprite.row * frame.height;
return (
<div className="reaction-preview-sprite-shell">
<svg className="reaction-preview-sprite" width={frame.width} height={frame.height} viewBox={`0 0 ${frame.width} ${frame.height}`} role="img" aria-label={`Animation: ${state}`}>
<image href={settings.previewSpriteUrl} x="0" y={y} width={frame.width * settings.sprite.columns} height={frame.height * settings.sprite.rows} preserveAspectRatio="none">
<animate attributeName="x" values={xValues} dur={`${sprite.durationMs}ms`} repeatCount="indefinite" calcMode="discrete" />
</image>
</svg>
</div>
);
}
function SettingsView() {
const [settings, setSettings] = useState<SettingsState | null>(null);
const [reactionSettings, setReactionSettings] = useState<ReactionAnimationSettings | null>(null);
const [launchAtLogin, setLaunchAtLogin] = useState<LaunchAtLoginState | null>(null);
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [activeTab, setActiveTab] = useState<"general" | "reactions">("general");
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const reactionSaveQueue = useRef(Promise.resolve());
async function loadSettings() {
setError("");
const [nextSettings, nextReactions, nextLaunch, nextUpdate] = await Promise.all([
api.getSettingsState(),
api.getReactionAnimationSettings(),
api.getLaunchAtLogin(),
api.getUpdateStatus(),
]);
setSettings(nextSettings);
setReactionSettings(nextReactions);
setLaunchAtLogin(nextLaunch);
setUpdateStatus(nextUpdate);
if (nextUpdate.state === "checking") {
void api.checkForUpdates().then(setUpdateStatus).catch((err) => setError(String(err?.message ?? err)));
}
}
useEffect(() => { void loadSettings().catch((err) => setError(String(err?.message ?? err))); }, []);
useEffect(() => {
if (!message) return;
const timeout = window.setTimeout(() => setMessage(""), 2200);
return () => window.clearTimeout(timeout);
}, [message]);
async function run(label: string, fn: () => Promise<void>) {
try { setBusy(label); setError(""); setMessage(""); await fn(); }
catch (err) { setError(String((err as Error)?.message ?? err)); }
finally { setBusy(""); }
}
function patchPreferences(patch: Partial<SettingsState["preferences"]>, success: string) {
void run("Saving", async () => {
const next = await api.updatePreferences(patch);
setSettings(next);
if ("reactionAnimationOverrides" in patch) {
setReactionSettings((current) => current ? { ...current, overrides: next.preferences.reactionAnimationOverrides ?? {} } : current);
}
setMessage(success);
});
}
function updateReactionOverride(reaction: ReactionAnimationSettings["reactions"][number], value: UserSelectableAnimationState) {
const queuedSave = reactionSaveQueue.current.catch(() => undefined).then(() => run("Saving", async () => {
const latestReactions = await api.getReactionAnimationSettings();
const nextOverrides = { ...(latestReactions.overrides ?? {}) };
if (value === reaction.defaultAnimation) delete nextOverrides[reaction.id];
else nextOverrides[reaction.id] = value;
const nextSettings = await api.updatePreferences({ reactionAnimationOverrides: nextOverrides });
setSettings(nextSettings);
setReactionSettings({ ...latestReactions, overrides: nextSettings.preferences.reactionAnimationOverrides ?? {} });
setMessage("Reaction animation saved.");
}));
reactionSaveQueue.current = queuedSave;
void queuedSave;
}
const overrides = settings?.preferences.reactionAnimationOverrides ?? {};
return <div className="settings-layout">
{error && <div className="error settings-message">{error}</div>}
{message && <div className="settings-success settings-message">{message}</div>}
<div className="settings-container">
<aside className="settings-sidebar">
<button className={`settings-nav-item ${activeTab === "general" ? "active" : ""}`} onClick={() => setActiveTab("general")}>
<SettingsIcon />
<span>General</span>
</button>
<button className={`settings-nav-item ${activeTab === "reactions" ? "active" : ""}`} onClick={() => setActiveTab("reactions")}>
<PetsIcon />
<span>Reaction Mapping</span>
</button>
</aside>
<main className="settings-content">
{activeTab === "general" && (
<div className="settings-section">
<p className="eyebrow">Environment</p>
<h2 className="settings-section-title">General Settings</h2>
<div className="settings-group">
<ToggleRow
title="Show pet on launch"
description="Keep OpenPets in the tray but hide the pet until requested."
checked={settings?.preferences.openDefaultPetOnLaunch ?? false}
disabled={!settings || !!busy}
onChange={(checked) => patchPreferences({ openDefaultPetOnLaunch: checked }, "Startup preference saved.")}
/>
<ToggleRow
title="Launch at login"
description={launchAtLogin?.supported ? "Start OpenPets automatically when your computer starts." : "Not supported on this platform."}
checked={launchAtLogin?.enabled ?? false}
disabled={!launchAtLogin?.supported || !!busy}
onChange={(checked) => void run("Saving", async () => { setLaunchAtLogin(await api.setLaunchAtLogin(checked)); setMessage("Login startup preference saved."); })}
/>
<div className="settings-row">
<div className="settings-row-info">
<strong>Pet scale</strong>
<small>Adjust how large the default desktop pet appears.</small>
</div>
<select className="settings-select" value={settings?.preferences.petScale ?? ""} disabled={!settings || !!busy} onChange={(event) => patchPreferences({ petScale: Number(event.target.value) }, "Pet scale saved.")}>
{(settings?.petScaleOptions ?? []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
</div>
<div className="settings-actions">
<Button variant="secondary" size="compact" disabled={!!busy} onClick={() => void run("Resetting", async () => { setSettings(await api.resetDefaultPetPosition()); setMessage("Default pet position reset."); })}>Reset Pet Position</Button>
</div>
<div className="settings-system-footer">
<div className="settings-system-info">
<RefreshIcon />
<span>System Status</span>
<span className="settings-system-version">{updateStatus?.currentVersion}</span>
<span className="opacity-60">{formatUpdateStatus(updateStatus)}</span>
</div>
<div className="flex gap-2">
{updateStatus?.state === "available" && (
<Button variant="primary" size="compact" disabled={!!busy} onClick={() => void run("Opening", async () => { await api.openUpdateReleasePage(); })}>Update Available</Button>
)}
<Button variant="secondary" size="compact" disabled={!!busy || updateStatus?.state === "checking"} onClick={() => void run("Checking", async () => { setUpdateStatus(await api.checkForUpdates()); })}>
{busy === "Checking" ? "Checking…" : "Check for Updates"}
</Button>
</div>
</div>
</div>
)}
{activeTab === "reactions" && (
<div className="settings-section">
<div className="flex items-center justify-between">
<div>
<p className="eyebrow">Behavior</p>
<h2 className="settings-section-title">Reaction Mapping</h2>
</div>
<Button variant="secondary" size="compact" disabled={!settings || !!busy || !Object.keys(overrides).length} onClick={() => patchPreferences({ reactionAnimationOverrides: {} }, "Reaction animations reset.")}>Reset to Defaults</Button>
</div>
<p className="text-sm text-slatecopy -mt-2 mb-2">Customize which animation plays for each agent reaction. Previews use the default pet.</p>
<div className="settings-group">
<div className="reaction-grid">
{(reactionSettings?.reactions ?? []).map((reaction) => {
const currentAnimation = overrides[reaction.id] ?? reaction.defaultAnimation;
return (
<div className="reaction-row" key={reaction.id}>
<div className="reaction-preview-box">
{reactionSettings?.previewSpriteUrl && (
<ReactionPreviewSprite settings={reactionSettings} state={currentAnimation} />
)}
</div>
<div className="reaction-info">
<strong>{reaction.label}</strong>
<small>{reaction.description}</small>
</div>
<select
className="settings-select"
value={currentAnimation}
disabled={!reactionSettings || !settings || !!busy}
onChange={(event) => {
const value = event.target.value as UserSelectableAnimationState;
updateReactionOverride(reaction, value);
}}
>
{(reactionSettings?.animations ?? []).map((animation) => (
<option key={animation.id} value={animation.id}>{animation.label}</option>
))}
</select>
</div>
);
})}
</div>
</div>
</div>
)}
</main>
</div>
</div>;
}
function App() {
const [currentRoute, setCurrentRoute] = useState<Route>("pets");
const [state, setState] = useState<StateSnapshot | null>(null);
@ -595,7 +848,9 @@ function App() {
{error && <div className="error">{error}</div>}
{currentRoute !== "pets" ? (
{currentRoute === "settings" ? (
<SettingsView />
) : currentRoute !== "pets" ? (
<PlaceholderView route={currentRoute} />
) : (
<div className="layout">

View file

@ -72,12 +72,12 @@
.btn-success:hover:not(:disabled) { background: linear-gradient(180deg, #6ee7b7, #059669); }
.btn-warning { border: 1px solid rgba(217, 119, 6, 0.32); background: linear-gradient(180deg, #fbbf24, #d97706); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10); }
.btn-warning:hover:not(:disabled) { background: linear-gradient(180deg, #fcd34d, #d97706); }
.btn-icon-wrapper { @apply flex items-center justify-center shrink-0; }
.btn-icon { @apply h-4 w-4 stroke-[2.5]; }
.pager-text { @apply text-xs font-bold uppercase tracking-wider text-slatecopy/80; }
.error { @apply mb-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-red-700; }
.nav-bar { @apply mb-5 flex items-center gap-1.5 border-b border-blue-200/30 pb-3.5 flex-wrap; }
.nav-tab { @apply rounded-xl border px-3.5 py-2 font-monoDisplay text-xs font-black uppercase tracking-wider text-slate-600 active:scale-[0.96] transition-[transform,background-color,border-color,box-shadow] duration-150 flex items-center gap-2 cursor-pointer select-none h-9 shadow-sm; border-color: rgba(126, 161, 210, 0.25); background: linear-gradient(180deg, rgba(255, 255, 255, 0.8) 0%, rgba(241, 245, 249, 0.8) 100%); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 2px 4px rgba(61,99,160,0.04); }
.nav-tab:hover:not(.active) { @apply text-brand; border-color: rgba(37, 99, 235, 0.3); background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(239, 246, 255, 0.8) 100%); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 2px 4px rgba(61,99,160,0.06); }
@ -85,4 +85,53 @@
.nav-tab.active:hover { background: linear-gradient(180deg, #55a6ff 0%, #176df2 100%); }
.nav-icon { @apply h-3.5 w-3.5 stroke-[2.5] opacity-75 shrink-0; }
.nav-tab.active .nav-icon { @apply opacity-100; }
.settings-layout { @apply relative flex flex-col gap-6 overflow-y-auto pr-2 h-full; overscroll-behavior: contain; }
.settings-container { @apply grid grid-cols-[280px_1fr] gap-6 min-h-0 flex-1; }
.settings-sidebar { @apply flex flex-col gap-2; }
.settings-content { @apply flex flex-col gap-6 overflow-y-auto pr-2 pb-8; overscroll-behavior: contain; }
.settings-nav-item { @apply flex items-center gap-3 px-4 py-3 rounded-2xl border border-transparent text-sm font-bold text-slatecopy transition-all duration-150 cursor-pointer select-none; }
.settings-nav-item:hover { @apply bg-white/50 text-brand border-blue-100/50; }
.settings-nav-item.active { @apply bg-white text-brand border-blue-200 shadow-sm ring-4 ring-blue-100/30; }
.settings-nav-icon { @apply w-4 h-4 opacity-70; }
.settings-nav-item.active .settings-nav-icon { @apply opacity-100; }
.settings-section { @apply flex flex-col gap-4; }
.settings-section-title { @apply m-0 font-monoDisplay text-2xl font-black text-navy; }
.settings-group { @apply flex flex-col overflow-hidden rounded-[24px] border border-blue-100/70 bg-white/55 shadow-sm; }
.settings-row { @apply flex items-center justify-between gap-4 p-5 transition-colors hover:bg-white/80 border-b border-blue-50 last:border-b-0; }
.settings-row-info { @apply flex-1 min-w-0; }
.settings-row-info strong { @apply block text-sm font-bold text-navy; }
.settings-row-info small { @apply mt-0.5 block text-xs leading-relaxed text-slatecopy; }
.settings-toggle { @apply h-6 w-11 shrink-0 cursor-pointer accent-brand disabled:cursor-not-allowed; }
.settings-select { @apply min-h-10 min-w-[140px] rounded-xl border border-blue-200/80 bg-white/80 px-3 py-2 text-sm font-semibold text-navy outline-none focus:border-brand focus:ring-4 focus:ring-brand/15 disabled:cursor-not-allowed disabled:opacity-60; }
.settings-system-footer { @apply mt-2 flex items-center justify-between gap-4 p-4 rounded-2xl bg-blue-50/40 border border-blue-100/50; }
.settings-system-info { @apply flex items-center gap-3 text-xs font-bold text-slatecopy; }
.settings-system-version { @apply px-2 py-0.5 rounded-md bg-blue-100/50 text-brand font-mono; }
.reaction-grid { @apply grid grid-cols-1 gap-px bg-blue-50; }
.reaction-row { @apply flex items-center gap-4 bg-white/80 p-4 transition-colors hover:bg-white; }
.reaction-preview-box { @apply flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-blue-50/50 border border-blue-100/50 overflow-hidden; }
.reaction-preview-sprite-shell { @apply flex h-12 w-12 items-center justify-center; }
.reaction-preview-sprite { display: block; width: 36px; height: 36px; flex: 0 0 auto; overflow: hidden; image-rendering: pixelated; }
.reaction-preview-sprite image { pointer-events: none; user-select: none; }
.reaction-info { @apply flex-1 min-w-0; }
.reaction-info strong { @apply block text-sm font-bold text-navy; }
.reaction-info small { @apply mt-0.5 block text-xs leading-relaxed text-slatecopy; }
.settings-panel { @apply flex flex-col gap-4 p-6; }
.settings-panel h2 { @apply m-0 font-monoDisplay text-2xl font-black text-navy; }
.settings-wide { @apply w-full; }
.settings-actions { @apply flex flex-wrap gap-3 mt-2; }
.settings-message { @apply fixed bottom-8 left-1/2 z-20 w-[min(360px,calc(100vw-3rem))] -translate-x-1/2 text-center shadow-lg; }
.settings-success { @apply mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-700; }
.update-card-content { @apply p-4 rounded-2xl bg-brand/5 border border-brand/10; }
.update-card-eyebrow { @apply text-xs text-brand font-bold uppercase tracking-wider mb-2; }
.update-card-text { @apply text-sm text-navy mb-4; }
.reaction-header { @apply flex items-center justify-between mb-2; }
.reaction-desc { @apply desc mb-2; }
}

View file

@ -87,6 +87,21 @@ function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; pets:
return { preferences: { defaultPetId: state.preferences.defaultPetId }, pets: state.pets };
}
function getSettingsStateSnapshot(): {
preferences: Pick<ReturnType<typeof getAppStateSnapshot>["preferences"], "openDefaultPetOnLaunch" | "petScale" | "reactionAnimationOverrides">;
petScaleOptions: typeof petScaleOptions;
} {
const state = getAppStateSnapshot();
return {
preferences: {
openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch,
petScale: state.preferences.petScale,
reactionAnimationOverrides: state.preferences.reactionAnimationOverrides,
},
petScaleOptions,
};
}
export function installInternalUiHandlers(): void {
if (internalUiHandlersInstalled) {
return;
@ -104,8 +119,13 @@ export function installInternalUiHandlers(): void {
return getPetsStateSnapshot();
});
ipcMain.handle("openpets:get-settings-state", (event) => {
assertAllowedSender(event, ["control-center"]);
return getSettingsStateSnapshot();
});
ipcMain.handle("openpets:get-reaction-animation-settings", async (event) => {
assertAllowedSender(event, ["settings"]);
assertAllowedSender(event, ["settings", "control-center"]);
return getReactionAnimationSettingsSnapshot();
});
@ -217,7 +237,7 @@ export function installInternalUiHandlers(): void {
});
ipcMain.handle("openpets:update-preferences", (event, patch: unknown) => {
assertAllowedSender(event, ["settings"]);
assertAllowedSender(event, ["settings", "control-center"]);
const previousScale = getAppStateSnapshot().preferences.petScale;
const previousOverrides = JSON.stringify(getAppStateSnapshot().preferences.reactionAnimationOverrides ?? {});
const state = updatePreferences(validatePreferencePatch(patch));
@ -226,16 +246,16 @@ export function installInternalUiHandlers(): void {
refreshDefaultPetContent();
refreshAgentPetContent();
}
return state;
return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getSettingsStateSnapshot() : state;
});
ipcMain.handle("openpets:get-launch-at-login", (event) => {
assertAllowedSender(event, ["settings"]);
assertAllowedSender(event, ["settings", "control-center"]);
return getLaunchAtLoginState();
});
ipcMain.handle("openpets:set-launch-at-login", (event, enabled: unknown) => {
assertAllowedSender(event, ["settings"]);
assertAllowedSender(event, ["settings", "control-center"]);
if (typeof enabled !== "boolean") throw new Error("Invalid launch-at-login value.");
if (!isLaunchAtLoginSupported()) return getLaunchAtLoginState();
app.setLoginItemSettings({ openAtLogin: enabled, openAsHidden: true });
@ -243,12 +263,12 @@ export function installInternalUiHandlers(): void {
});
ipcMain.handle("openpets:get-update-status", (event) => {
assertAllowedSender(event, ["settings"]);
assertAllowedSender(event, ["settings", "control-center"]);
return getUpdateStatus();
});
ipcMain.handle("openpets:check-for-updates", async (event) => {
assertAllowedSender(event, ["settings"]);
assertAllowedSender(event, ["settings", "control-center"]);
const status = await checkForGitHubReleaseUpdate();
const { refreshTrayMenu } = await import("./tray.js");
refreshTrayMenu();
@ -256,7 +276,7 @@ export function installInternalUiHandlers(): void {
});
ipcMain.handle("openpets:open-update-release-page", async (event) => {
assertAllowedSender(event, ["settings"]);
assertAllowedSender(event, ["settings", "control-center"]);
await openUpdateReleasePage();
});
@ -305,9 +325,9 @@ export function installInternalUiHandlers(): void {
});
ipcMain.handle("openpets:reset-default-pet-position", (event) => {
assertAllowedSender(event, ["settings"]);
assertAllowedSender(event, ["settings", "control-center"]);
resetDefaultPetToInitialPosition();
return getAppStateSnapshot();
return getInternalUiWindowKindForWebContents(event.sender.id) === "control-center" ? getSettingsStateSnapshot() : getAppStateSnapshot();
});
ipcMain.handle("openpets:agent-setup-snapshot", async (event, selectedPetId: unknown, commandMode: unknown) => {

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:; 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:; 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-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'";
export default defineConfig(({ command }) => ({
root: "src/renderer",