From 3c119e209d458590959f4ed4284c1a791df8ba46 Mon Sep 17 00:00:00 2001 From: Alvin Unreal Date: Sat, 23 May 2026 23:04:25 +0200 Subject: [PATCH] Add control center settings page --- apps/desktop/control-center-preload.cjs | 9 + apps/desktop/src/renderer/index.html | 2 +- apps/desktop/src/renderer/src/main.tsx | 261 ++++++++++++++++++++++- apps/desktop/src/renderer/src/styles.css | 53 ++++- apps/desktop/src/windows.ts | 40 +++- apps/desktop/vite.config.ts | 4 +- 6 files changed, 351 insertions(+), 18 deletions(-) diff --git a/apps/desktop/control-center-preload.cjs b/apps/desktop/control-center-preload.cjs index 7057c7f1..f4f969aa 100644 --- a/apps/desktop/control-center-preload.cjs +++ b/apps/desktop/control-center-preload.cjs @@ -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"), diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index b69b194e..964af57f 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -2,7 +2,7 @@ - + OpenPets Control Center diff --git a/apps/desktop/src/renderer/src/main.tsx b/apps/desktop/src/renderer/src/main.tsx index 11de77fa..c6b2ca19 100644 --- a/apps/desktop/src/renderer/src/main.tsx +++ b/apps/desktop/src/renderer/src/main.tsx @@ -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; getCatalog(): Promise; getCatalogPage(page: number): Promise; getCatalogSearch(): Promise<{ pets: SearchPetEntry[]; error?: string }>; getCodexPets(): Promise; setDefaultPet(petId: string): Promise; installPet(petId: string): Promise; importCodexPet(petId: string): Promise; removePet(petId: string): Promise } }).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; +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 }; overrides: ReactionAnimationOverrides; previewSpriteUrl: string }; +type ControlCenterApi = { + getPetsState(): Promise; + getSettingsState(): Promise; + updatePreferences(patch: Partial): Promise; + getReactionAnimationSettings(): Promise; + getLaunchAtLogin(): Promise; + setLaunchAtLogin(enabled: boolean): Promise; + getUpdateStatus(): Promise; + checkForUpdates(): Promise; + openUpdateReleasePage(): Promise; + resetDefaultPetPosition(): Promise; + getCatalog(): Promise; + getCatalogPage(page: number): Promise; + getCatalogSearch(): Promise<{ pets: SearchPetEntry[]; error?: string }>; + getCodexPets(): Promise; + setDefaultPet(petId: string): Promise; + installPet(petId: string): Promise; + importCodexPet(petId: string): Promise; + removePet(petId: string): Promise; +}; + +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 {alt} 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 ; +} + +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 ( +
+ + + + + +
+ ); +} + +function SettingsView() { + const [settings, setSettings] = useState(null); + const [reactionSettings, setReactionSettings] = useState(null); + const [launchAtLogin, setLaunchAtLogin] = useState(null); + const [updateStatus, setUpdateStatus] = useState(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) { + try { setBusy(label); setError(""); setMessage(""); await fn(); } + catch (err) { setError(String((err as Error)?.message ?? err)); } + finally { setBusy(""); } + } + + function patchPreferences(patch: Partial, 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
+ {error &&
{error}
} + {message &&
{message}
} + +
+ + +
+ {activeTab === "general" && ( +
+

Environment

+

General Settings

+ +
+ patchPreferences({ openDefaultPetOnLaunch: checked }, "Startup preference saved.")} + /> + void run("Saving", async () => { setLaunchAtLogin(await api.setLaunchAtLogin(checked)); setMessage("Login startup preference saved."); })} + /> +
+
+ Pet scale + Adjust how large the default desktop pet appears. +
+ +
+
+ +
+ +
+ +
+
+ + System Status + {updateStatus?.currentVersion} + {formatUpdateStatus(updateStatus)} +
+
+ {updateStatus?.state === "available" && ( + + )} + +
+
+
+ )} + + {activeTab === "reactions" && ( +
+
+
+

Behavior

+

Reaction Mapping

+
+ +
+

Customize which animation plays for each agent reaction. Previews use the default pet.

+ +
+
+ {(reactionSettings?.reactions ?? []).map((reaction) => { + const currentAnimation = overrides[reaction.id] ?? reaction.defaultAnimation; + return ( +
+
+ {reactionSettings?.previewSpriteUrl && ( + + )} +
+
+ {reaction.label} + {reaction.description} +
+ +
+ ); + })} +
+
+
+ )} +
+
+
; +} + function App() { const [currentRoute, setCurrentRoute] = useState("pets"); const [state, setState] = useState(null); @@ -595,7 +848,9 @@ function App() { {error &&
{error}
} - {currentRoute !== "pets" ? ( + {currentRoute === "settings" ? ( + + ) : currentRoute !== "pets" ? ( ) : (
diff --git a/apps/desktop/src/renderer/src/styles.css b/apps/desktop/src/renderer/src/styles.css index 0e8b5de2..d500bfce 100644 --- a/apps/desktop/src/renderer/src/styles.css +++ b/apps/desktop/src/renderer/src/styles.css @@ -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; } + } diff --git a/apps/desktop/src/windows.ts b/apps/desktop/src/windows.ts index fac83c60..f4e02a32 100644 --- a/apps/desktop/src/windows.ts +++ b/apps/desktop/src/windows.ts @@ -87,6 +87,21 @@ function getPetsStateSnapshot(): { preferences: { defaultPetId: string }; pets: return { preferences: { defaultPetId: state.preferences.defaultPetId }, pets: state.pets }; } +function getSettingsStateSnapshot(): { + preferences: Pick["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) => { diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index b0585967..277ee967 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -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",