Merge pull request #31 from alvinunreal/b

Refactor desktop control center
This commit is contained in:
Alvin 2026-05-24 12:14:55 +02:00 committed by GitHub
commit d19889b378
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 5078 additions and 3908 deletions

View file

@ -1,7 +1,7 @@
{
"metadata": {
"version": "1.0.0",
"last_run": "2026-05-19T17:03:36.901Z",
"last_run": "2026-05-24T09:29:48.346Z",
"root": "/Volumes/external/repos/pets",
"include_patterns": [
"apps/**/*.ts",
@ -30,19 +30,19 @@
"file_hashes": {
"apps/desktop/contracts/catalog-fixture.contract.ts": "8bc625d00ea7cc7dd3c3c846d517ad0a",
"apps/desktop/contracts/local-ipc-protocol.contract.ts": "76d45670b202ebd96cbee64d1e8e53ec",
"apps/desktop/contracts/plugin-manifest.contract.ts": "4ac9fd1cb75926e3d844c1bdeeb4a730",
"apps/desktop/package.json": "d18219b7abe65ddee9fb37147b999933",
"apps/desktop/contracts/plugin-manifest.contract.ts": "2b0eaa64d1e02dce6b14cfee9c6d2b3d",
"apps/desktop/package.json": "b83883566725e5c9a97fc9d7b5fd189a",
"apps/desktop/src/agent-pet-controller.ts": "179491dc5e838b5cf042b4630380d1e5",
"apps/desktop/src/agent-setup.ts": "ae02936221b2d934e28ee023fb93c19b",
"apps/desktop/src/agent-setup.ts": "c2193dd06fa7686b4446ea7dc5686b92",
"apps/desktop/src/app-state-core.ts": "9f4ea019ddb620bf8ad70dfe83f27496",
"apps/desktop/src/app-state.ts": "8cb602e19fd651c75942f2e6755e271f",
"apps/desktop/src/app-state.ts": "89a68328e992076d114b90de5b8fbe1b",
"apps/desktop/src/assets.ts": "d59129388a45ae10b5319242fd7a67ee",
"apps/desktop/src/built-in-pet.ts": "eff5b3375b2a9eccad401f6048c9c5f1",
"apps/desktop/src/catalog-validation.ts": "6bd6974357781c0f972ef2b2da76eb12",
"apps/desktop/src/catalog.ts": "37fe90b36fa2eee976c3617fe1ee5989",
"apps/desktop/src/check-cursor-desktop.ts": "80b0d5c1913a47143970447153762617",
"apps/desktop/src/check-opencode-desktop-setup.ts": "08b86952c137b2e917d296c0c4590e5c",
"apps/desktop/src/check-packaging-contract.ts": "8fef67305ffde508139ec5f3fca4651e",
"apps/desktop/src/check-packaging-contract.ts": "c5f74134e8e5e98623e2c69e71a79eec",
"apps/desktop/src/claude-memory.ts": "37f0a29734fad5e2bb5bd97110e7ddfa",
"apps/desktop/src/codex-pets-core.ts": "f20c529df55934b632ac8e02dec4df15",
"apps/desktop/src/codex-pets.ts": "885f214e14db799953b1ccf13a264478",
@ -52,37 +52,40 @@
"apps/desktop/src/lifecycle.ts": "a346d3ebe789e6d434858481af0757d0",
"apps/desktop/src/local-ipc-paths.ts": "d69f7e0c0a451576d8fe02932c977f6f",
"apps/desktop/src/local-ipc-protocol.ts": "738dee33acf186895798ceb42637ef88",
"apps/desktop/src/local-ipc.ts": "939ccc0c7579db7018d1b547e1f85c83",
"apps/desktop/src/logger.ts": "029c9601130c7fdc14c3db7e9f0a5f0b",
"apps/desktop/src/main.ts": "0566296a91112db4aba9125b1963cb7b",
"apps/desktop/src/local-ipc.ts": "a8f25fd0b47d560782f2332e30acc115",
"apps/desktop/src/logger.ts": "a92e8de9ede984afc378d2d29d5fce0e",
"apps/desktop/src/main.ts": "5678745b8ea5c311ada5259545bb694e",
"apps/desktop/src/pet-installation.ts": "79cdc67778448f2e3876d22194c1ada1",
"apps/desktop/src/pet-paths.ts": "73d7313fe19254ea0dcf24ab2f0929b3",
"apps/desktop/src/pet-window.ts": "5fafece7a49d2c43fa42d84f327c4a83",
"apps/desktop/src/plugin-catalog-validation.ts": "cf2e46636198dbd8f858a4e7eef40200",
"apps/desktop/src/plugin-catalog.ts": "d765406c83155f17426cb2137dca6797",
"apps/desktop/src/plugin-config.ts": "1472f4bc50d7af72e129d069499eb3bc",
"apps/desktop/src/plugin-local-loader.ts": "3b4098baa8829312d81105ce4da948ba",
"apps/desktop/src/plugin-catalog-validation.ts": "507deea55077a1130f9d4623a8fe8342",
"apps/desktop/src/plugin-catalog.ts": "10cffa1505a621f2e4ec4d4e1379823c",
"apps/desktop/src/plugin-config.ts": "4f1f0b6a83d6f705c7ef7b98b2412677",
"apps/desktop/src/plugin-js-host.ts": "c61f52c9395897cb35c430ccb5cf7211",
"apps/desktop/src/plugin-local-loader.ts": "6c95a5b381acea3b12ff9a4d90ca96d9",
"apps/desktop/src/plugin-manifest-reader.ts": "2ba5d272ed3ae1b7e0131d2efdaf4caf",
"apps/desktop/src/plugin-manifest.ts": "a6d0a95f946c31a68f32c870498b350e",
"apps/desktop/src/plugin-package.ts": "069583f455f7c94144eb1385bc1dfe3e",
"apps/desktop/src/plugin-manifest.ts": "15fe4e34408053cebe8b1b42e84cc6e7",
"apps/desktop/src/plugin-package.ts": "2aa09526568e4fd4f961d883ef373a38",
"apps/desktop/src/plugin-pet-api.ts": "b6d35ff512c9954462d82bf84dc50929",
"apps/desktop/src/plugin-runtime.ts": "fad660f5dcf02bd359e4d3220be82e36",
"apps/desktop/src/plugin-service.ts": "2aaa02e8860848ff8737b5e290dc5cc9",
"apps/desktop/src/plugin-state.ts": "903457ed258ac93b636ebb903956a8f0",
"apps/desktop/src/plugins-window.ts": "3a3dac2b433474eaf2c4928e6ad88a43",
"apps/desktop/src/plugin-runtime.ts": "5065842b784ca91207bf8e6ce3d1fab5",
"apps/desktop/src/plugin-sdk-bridge.ts": "a925bdd85834f00474e058e2c711d097",
"apps/desktop/src/plugin-service.ts": "7a6ecfc1f62a35e5c6d440e876dac788",
"apps/desktop/src/plugin-state.ts": "0f4575eec3a682c1256ed9aebe4003fc",
"apps/desktop/src/reaction-animation-mapping.ts": "4353bdbca4bbfc2a9f70520f84cd2688",
"apps/desktop/src/reaction-messages.ts": "99ec6ea6bf33cc8cc05cc895394e906a",
"apps/desktop/src/renderer/src/vite-env.d.ts": "0352474ba2918efe13895edbc3780d94",
"apps/desktop/src/state.ts": "34ff332215f43ab6ee0808018cbd1751",
"apps/desktop/src/tray.ts": "e7efb701d33a56a48f40d324285ee06b",
"apps/desktop/src/tray.ts": "152d6564121ccd19f4a814b8245a671c",
"apps/desktop/src/update-checker.ts": "5f835427f52203f31c391eca7ac0634c",
"apps/desktop/src/update-version.ts": "b8777871b655a26a04c0d6a92481664c",
"apps/desktop/src/windows.ts": "7cc05fce6a874422849cbcc9c45b2f78",
"apps/desktop/src/windows.ts": "4fe40c9a7f481fbab56cabb2fe284416",
"apps/desktop/src/zip-safety.ts": "6e28cf03514254df74eb11e86db28d95",
"package.json": "f25b55039f4dd82b83e701c0a9f241b8",
"packages/agent-events/package.json": "c614b1baaa5ccfafb746c021d5f7cad7",
"apps/desktop/vite.config.ts": "a2bd54426b257c13382e1c31065e5e51",
"package.json": "659e909fc918656a9a3966cc740d2c4b",
"packages/agent-events/package.json": "878d4cfd96549a41d93e7664912b1e57",
"packages/agent-events/src/check-agent-events.ts": "c187e50df456d0ce83577c60a57564b4",
"packages/agent-events/src/index.ts": "4c13fcc2e753390dd76524604bcbf555",
"packages/claude/package.json": "439f51161fb533a0a33dc999f1f38925",
"packages/claude/package.json": "0fd8a845544140e1e0285ff19f0c036c",
"packages/claude/src/check-claude-code.ts": "79d583dec9d233e4c7791be466cec238",
"packages/claude/src/check-claude-hooks.ts": "ad66153c3640f194bc72e310e70c7a8c",
"packages/claude/src/claude-code.ts": "556806d7877488197915e12868a8b5cc",
@ -91,46 +94,46 @@
"packages/claude/src/hook-settings.ts": "0f18ba910e0b9534c695171e03fc29e8",
"packages/claude/src/hooks.ts": "ff9ec6c55a2a779cd4b435219e97ef91",
"packages/claude/src/index.ts": "13c65ce7d312b8bd08230195e0375ea5",
"packages/cli/package.json": "9562cd5c31dc699455958c57386d389e",
"packages/cli/package.json": "d30d63ad97c8953458692ce4048db0bd",
"packages/cli/src/check-cli-contract.ts": "65e6488c8164b988f8758c0830bb3cb3",
"packages/cli/src/index.ts": "70a27cdb0f986375f1ef945b51b5849c",
"packages/client/contracts/client-protocol.contract.ts": "8d0f2908bdfd27341368dbaa1e12daed",
"packages/client/package.json": "8282c3c4791a9591826b55b8dc7cdca7",
"packages/client/package.json": "6eb5a6e9f959f79809de6623bf0859ca",
"packages/client/src/discovery.ts": "ef4cd1346a516138226ba3659f8f73cf",
"packages/client/src/index.ts": "3ed10c5a40d0c592d68833b27298d88b",
"packages/client/src/protocol.ts": "f53294f725838877c19163642714a5d5",
"packages/client/src/smoke.ts": "a80c46288c29e092899fad48d7dc013d",
"packages/cursor/package.json": "46dc8d2ef47cf13853d1b3ff21c00194",
"packages/cursor/package.json": "2214ed073ae8b9e24aa825f370a9bc5d",
"packages/cursor/src/check-cursor.ts": "0b799f60156357cd5132eace2456097b",
"packages/cursor/src/cursor-mcp.ts": "9c97c6f60665b5117da3fec3eaf6355a",
"packages/cursor/src/cursor-previews.ts": "f253c1be8dee11cc878a6e80944e87f4",
"packages/cursor/src/cursor-rules.ts": "a05f35efb92e61e2493ca122bcf42aab",
"packages/cursor/src/cursor-status.ts": "c0d0a4dc377998336cf034c82d5bf56a",
"packages/cursor/src/index.ts": "324347811550c7206820993f68651cba",
"packages/install-pet/package.json": "e7ea02be04b33e6eb6d162c354ae0a7b",
"packages/install-pet/package.json": "171c0fdaa8b13597c67c8c06be7adc6d",
"packages/install-pet/src/check-install-pet.ts": "de740e86393f3d7e4e30bf807c8e489c",
"packages/install-pet/src/index.ts": "04eaea5b38365061ae310c22626e077e",
"packages/mcp/package.json": "e94983978c83cd0452586dcfcba6bf1c",
"packages/mcp/package.json": "220512b0569b05ea9ec17950df4eb429",
"packages/mcp/src/args.ts": "6b84e85ab049f35181294be5ad3efb7a",
"packages/mcp/src/check-mcp-contract.ts": "0029e9dc79f600482b4c3d1ff1a0e630",
"packages/mcp/src/ensure-executable.ts": "fab391e226668986f799293dce3239f5",
"packages/mcp/src/index.ts": "af3f4b9aa83e42551290b63586fa643b",
"packages/mcp/src/server.ts": "f7edb6891b9a3baf7266772b03c28a5e",
"packages/mcp/src/tools.ts": "57b4e96d956da0acd0c21275c4953a27",
"packages/opencode/package.json": "826da5b70e48fb269ff6f0fdb4b2f6f3",
"packages/opencode/src/check-opencode-foundation.ts": "d4561f5bf88414cafef76d26ec8fc374",
"packages/opencode/package.json": "8704c5e7f5737e71e918b5df849ce002",
"packages/opencode/src/check-opencode-foundation.ts": "519e9712de23f60f723de265ff6f52f6",
"packages/opencode/src/check-opencode-plugin.ts": "63c0f3b3782cb7501c1c31960a35c7c1",
"packages/opencode/src/index.ts": "4931cbee8e079a669de99c7eebb0c9ce",
"packages/opencode/src/opencode-config.ts": "b36fc3f17182e616680c3c74d9a60994",
"packages/opencode/src/opencode-global-setup.ts": "0ffef4e42ecd914086e32e74c2ceb599",
"packages/opencode/src/opencode-plugin-runtime.ts": "13077dc79254691664c8e1398feec6d6",
"packages/opencode/src/opencode-previews.ts": "938cdd7e69da050d1dc9e07951687dcf",
"packages/opencode/src/opencode-previews.ts": "7a7af0e3f189b0989a180143b04ba8ec",
"packages/opencode/src/opencode-project-setup.ts": "84b378ea0b22f9a99eced35edd5e584b",
"packages/opencode/src/opencode-status.ts": "f4c9b5a1c236ea2a3b9bd1c60d04de2b",
"packages/opencode/src/opencode-status.ts": "2640eed91327a35c57a7162ab6730ccc",
"packages/opencode/src/plugin.ts": "8265624c6f4d87274928dde47405d893",
"packages/pet-format/package.json": "2b9a2455b93be85ce69473687b31706d",
"packages/pet-format/package.json": "de5f4dee65e316fd9968a07928ec6558",
"packages/pet-format/src/index.ts": "6e94caf8463f6c6139f8f7364691f2e4",
"packages/pi/package.json": "f38f3401a6afeff030685067e43501c3",
"packages/pi/package.json": "e539f5df26ab1f7aabe73ef0f924ebb6",
"packages/pi/src/check-pi-compat.ts": "e2f02e876e051e7f302f9ccf835953e3",
"packages/pi/src/check-pi.ts": "47a4f7d28b07fffe130419975f1cc7a4",
"packages/pi/src/extension.ts": "b69b2da9b0a6e5814821dea20d6bf762",
@ -139,32 +142,34 @@
"pnpm-workspace.yaml": "a01ad104a2079e9efc5cb7aed169a93f"
},
"folder_hashes": {
".": "4f805be432bed1abe6f3b2a587467dba",
"apps": "b66e403c629764bbd4dd12760b6e74ab",
"apps/desktop": "b66e403c629764bbd4dd12760b6e74ab",
"apps/desktop/contracts": "20452e51d177d12d265d06642d4c3742",
"apps/desktop/src": "86ebc20c1d90ad40889579e65065f05b",
"packages": "1c25af6ba0c1ec45af6b667d97e2c7a5",
"packages/agent-events": "6237e2a2220091929e2a9622c3a16714",
".": "95a8be0f54a3b04c5c47e8c4f4eedaba",
"apps": "fc772196f99084757739f4ec7cd5c56e",
"apps/desktop": "fc772196f99084757739f4ec7cd5c56e",
"apps/desktop/contracts": "5287707550b8f45a90cf8d8bfe0a242b",
"apps/desktop/src": "2e7ab76b12f0a66080299739e930f991",
"apps/desktop/src/renderer": "cf339a80cf66de177922ec0b5aa1c6b3",
"apps/desktop/src/renderer/src": "cf339a80cf66de177922ec0b5aa1c6b3",
"packages": "f988173854d800bfe8150ceaec477b89",
"packages/agent-events": "e36c79c9b0c15cda8b2094d5a444682d",
"packages/agent-events/src": "37e55a8851036d8a3e571c8c984e733f",
"packages/claude": "d2c8126176367877921ec86b44ff1b11",
"packages/claude": "78d460759fc5fd436fd09a92933d1918",
"packages/claude/src": "99464dfa6a8f3791f6103514f81cfae0",
"packages/cli": "7b40b9441951b158053b267b2dda4de3",
"packages/cli": "2a347b33d0d889393133099f98b52f5d",
"packages/cli/src": "f896d8bd032894346d84ff2354556039",
"packages/client": "65944320e0e308c468818e1447b5f9ab",
"packages/client": "a2b8e2dc2549beeedff2f322feded942",
"packages/client/contracts": "5228f030046512baba4f7270556b0fb7",
"packages/client/src": "a036a1f04099db816b2cd6b552fda034",
"packages/cursor": "65f3a67d966b2f268634a75fbc0d81c9",
"packages/cursor": "bc481b08e9fbe5e0464e6aef98f8c2ef",
"packages/cursor/src": "e6ff31505d96d16dcbea0afcb680d2a3",
"packages/install-pet": "8f24d0e2f6098debca5c52f2771a02d8",
"packages/install-pet": "1ead7cc70de4c772c4c230c446d5e4ad",
"packages/install-pet/src": "5882ef53808f2a7d7152446d1d46e196",
"packages/mcp": "5f1970322674ed1ee75905f9cd748b65",
"packages/mcp": "786e8bc32d478dbaa34bc3d1831cc6ec",
"packages/mcp/src": "e89d476284c4ec2814c11a064fcb210b",
"packages/opencode": "55a7384eb97145708cd21d89b96a0e59",
"packages/opencode/src": "e095ac6c38594178a0a02e73919c2a7d",
"packages/pet-format": "ec95c6f633b051bb148e6eb480f657e2",
"packages/opencode": "dd2382941a7bfe672b2f27dcbfa51e45",
"packages/opencode/src": "ed024a6207b42efd076da44ab9587adb",
"packages/pet-format": "ca85af1412e98214135e22bdcba5f406",
"packages/pet-format/src": "e01c13d845950b7c825b8ecde0c12b80",
"packages/pi": "c5cc86775e4b68234ff7b3453bf105d7",
"packages/pi": "df51ca2497141871a94e3383d042f051",
"packages/pi/src": "2a70d56d1b7ce40ed8a0416141834dd4"
}
}

View file

@ -8,3 +8,16 @@ Before working on any task, read `codemap.md` to understand:
- Data flow and integration points between modules
For deep work on a specific folder, also read that folder's `codemap.md`.
## Catalog Direction
Catalog v2 is legacy and exists only for old app versions/fallback compatibility.
For new work, migrations, and Control Center UI, do not optimize for v2 behavior.
Use catalog v3 (`thumbnail`, `spritesheet`, paginated pages, and search index) as the source of truth.
## Logging for Fast DX
When working on desktop UI, renderer, IPC, catalog, plugin, or pet-window behavior, add targeted logging as part of the implementation when it helps diagnose issues quickly.
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.

201
DESIGN.md Normal file
View file

@ -0,0 +1,201 @@
# OpenPets Control Center: Design System & Engineering Manual
This document outlines the visual direction, component patterns, and front-end engineering guidelines for the OpenPets Control Center. It serves as a practical implementation guide for migrating existing views (Settings, Plugins, Integrations, Onboarding) and building new pages using a cohesive, high-fidelity tactile design system.
---
## 1. Visual Direction & Heritage
The OpenPets Control Center blends the nostalgic, playful charm of late-90s/early-2000s desktop pet managers with a modern, glassmorphic utility interface.
- **The Core Tension**: Playful nostalgia meets crisp, modern desktop productivity.
- **Atmospheric Depth**: Instead of flat, sterile surfaces, interfaces leverage rich color gradients, subtle mesh backdrops, structured panels, and tactile control elements.
- **Physicality**: Interactive components feel physical. They have distinct borders, inset highlights, drop shadows, and visual feedback on click/press.
---
## 2. Backgrounds & Surface Treatments
Surfaces are layered to establish clear spatial hierarchy and keep the interface readable.
### Page Backdrop
- **Formula**: A radial ambient glow on top of a subtle linear gradient.
- **Implementation**:
```css
background: radial-gradient(circle at 12% 8%, rgba(219, 234, 254, 0.9), transparent 24%),
linear-gradient(180deg, #f8fbff 0%, #eff7ff 54%, #e9f3ff 100%);
```
### Glass Panels (`.glass` / `GlassCard`)
- Panels use semi-transparent white fills coupled with a distinct double-border look (using inset box shadows) to simulate thick, polished acrylic sheets.
- **Specifications**:
- Background: `rgba(255, 255, 255, 0.76)`
- Border: `1px solid rgba(126, 161, 210, 0.48)`
- Inner Highlight: `inset 0 1px 0 rgba(255, 255, 255, 0.96)`
- Shadow: `0 24px 60px rgba(61, 99, 160, 0.15)`
- Blur: `backdrop-blur-xl`
- Border Radius: `28px` (`rounded-[28px]`)
---
## 3. Page Shell, Layout & Grids
The desktop shell is designed to be self-contained, fitting exactly within the viewport height without scrolling the entire window.
- **Page Shell**: Max-width of `1160px` centered, utilizing a full-height flexbox column (`h-screen p-6`).
- **Hero Header**: A balanced layout placing title text and description on the left (`.hero-content`) and the wider brand artwork (`openpets.webp`) on the right (`.hero-logo-container`). Keep artwork large enough to feel intentional, use `object-contain`, and avoid rounded wrappers or distortion.
- **Layout Split**: A clean grid split, typically:
- **Left Gallery/List Panel**: `1.25fr` — for browsing, searching, and filtering.
- **Right Detail/Action Panel**: `0.75fr` — for contextual inspection and heavy operations.
- **Scroll Containers**: Sub-panels (like grids and lists) scroll internally (`overflow-y-auto`) with customized scrollbars so that headers, filters, and primary action bars remain pinned.
---
## 4. Typography & Tone
- **Font Family**: Refined sans-serif (e.g., `Inter`, `system-ui`) for maximum readability in body copy, labels, and metadata.
- **Display Typography**: Strong, characterful monospace or display-sans fonts (e.g., Lucida Console, geometric display fonts) for headers and uppercase labels to evoke retro desktop applications.
- **Typographic Hierarchy**:
- **Hero Title**: `text-5xl font-black tracking-tight`
- **Panel Title**: `text-3xl font-black`
- **Eyebrow / Section Header**: `text-xs font-bold uppercase tracking-[.18em] text-brand`
- **Card Title**: `text-sm font-semibold text-navy`
- **Metadata / Descriptions**: `text-xs text-slate-500`
- **Copy Tone**: Conversational, clean, and helpful. Avoid overly dense technical jargon; explain state transitions clearly (e.g., *"Ready to become your default pet"* instead of *"State: INSTALLED, ACTIVE: FALSE"*).
---
## 5. Colors & Semantic Palette
All colors are mapped to functional roles to ensure consistency across themes.
| Role | Color / Hex | Tailwind Equivalent | Semantic Usage |
| :--- | :--- | :--- | :--- |
| **Brand Primary** | `#176df2` / `#3b96ff` | `bg-brand` / `text-brand` | Active filters, primary buttons, hero accents |
| **Dark Neutral** | `#102149` | `text-navy` | Main body text, card titles, heavy headings |
| **Light Neutral** | `#f8fbff` to `#e9f3ff` | `bg-slate-50` / `bg-blue-50` | Page backdrops, card backgrounds, empty states |
| **Slate Copy** | `#5c6e91` | `text-slatecopy` | Subtitles, helper text, inactive buttons, labels |
| **Success / Ready** | `#059669` / `#34d399` | `emerald` | Active indicators, successfully installed badges |
| **Warning / Import** | `#d97706` / `#fbbf24` | `amber` | Codex source indicators, imports, attention states |
| **Featured** | `#7e22ce` / `#a855f7` | `purple` | Featured catalog filters and badges |
| **Originals** | `#ca8a04` / `#facc15` | `yellow` | Original OpenPets catalog filters and badges |
| **Danger / Remove** | `#dc2626` / `#ff6b6b` | `red` | Destructive actions, broken states, error messages |
---
## 6. Tactile Button System
Buttons are the core interactive elements. They reject flat modern trends in favor of a chunky, tangible 3D appearance inspired by physical pet hardware and OS controls.
### Visual Architecture
- **Borders**: Darker semi-transparent bottom/side borders (`rgba(..., 0.32)`).
- **Gradients**: Linear vertical gradients that transition from a lighter top to a richer bottom.
- **Inset Highlights**: A subtle light line (`inset 0 1px 0 rgba(255,255,255,0.38)`) on the top edge to simulate light catching the button's crown.
- **Shadows**: Keep buttons grounded. Use subtle neutral depth plus inset highlights; avoid large colored glow shadows.
### Interactive Rules
- **Hover States**: Under current design guidelines, **avoid dramatic hover glows or float lifts**. Hovering should simply shift the gradient/background color slightly to maintain a grounded, crisp, and predictable interface.
- **Active Click State**: On press, the button scales down slightly to `0.96` to provide a satisfying physical click response.
- **Constraint**: Always use targeted transitions (`transition-[transform,background-color,border-color,box-shadow]`) instead of `transition-all` to prevent layout reflow stutter.
### Button Variants
1. **Primary** (`.btn-primary`):
- Gradient: `#3b96ff` to `#176df2`
- Shadow: `inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10)`
2. **Secondary** (`.btn-secondary`):
- Surface: `rgba(255,255,255,0.76)` with text `#176df2`
- Border: `rgba(37, 99, 235, 0.42)`
3. **Danger** (`.btn-danger`):
- Gradient: `#ff6b6b` to `#dc2626`
- Shadow: same subtle neutral depth as primary; do not add red glow
4. **Warning** (`.btn-warning`):
- Gradient: `#fbbf24` to `#d97706`
5. **Success** (`.btn-success`):
- Gradient: `#34d399` to `#059669`
6. **Compact Button** (`.btn-compact`):
- Designed for pagination (`.pager`) and secondary rows.
- Reduces size to `min-h-[30px]`, padding to `px-2.5 py-1`, and font size to `text-xs` with a tighter border-radius (`rounded-xl`).
---
## 7. Controls & Navigation Elements
### Filters (`.filter`)
- Instead of simple pill badges, filters are structured as miniature tactile buttons.
- Inactive filters use a soft white-to-slate gradient with a light blue-grey border.
- Active filters swap to semantic gradients with only subtle grounded depth:
- Brand blue for general filters (`All`, `Installed`, `Western`, `Asian`, `Codex`)
- Purple for `Featured`
- Amber/yellow-orange for `Originals`
- Match catalog v3/web gallery taxonomy where possible: `Featured` excludes originals, and category filters like `Western`/`Asian` exclude featured and originals.
### Pet Cards & Badges
- Card grids need inner padding so selected rings and focus states are not clipped by the scroll container.
- Use badges to expose catalog identity, not just install state:
- `Original` for original/OpenPets pets
- `Featured` for featured non-original catalog pets
- `Western` / `Asian` for standard category pets
- `Default`, `Installed`, and `Codex` for local state/source
- Avoid low-value detail badges such as `Available`; reserve detail badges for meaningful state or catalog identity.
### Search Inputs (`.search`)
- Rendered as soft, inset text boxes.
- Styled with `bg-white/80` and `shadow-inner` to look recessed into the panel.
- Focus states use a visible but restrained brand ring (`focus:ring-brand/15 focus:border-brand`) for accessibility.
### Pager (`.pager`)
- Placed cleanly at the **bottom** of list/grid containers.
- Merges compact secondary buttons with centered, bold, uppercase status text (`text-xs font-bold tracking-wider`).
---
## 8. Icons & Visual Assets
- **Coherency**: Use clean, outline-style vectors with a consistent stroke weight (`stroke-width="2.5"` or `3` for smaller shapes).
- **Style Alignment**: Align with the **Lucide / Iconify** icon libraries.
- **Implementation**: Embed icons as stateless, inline React SVG components or a localized icon catalog within the component file to avoid heavy runtime dependencies.
- **Sizing**:
- Action/Button Icons: `16px x 16px`
- Compact/Filter Icons: `12px x 12px` or `14px x 14px`
---
## 9. React & Tailwind Implementation Notes
### The Static Purge Constraint
Tailwind CSS extracts classes statically by analyzing source code strings. **Do not construct class names dynamically**.
```tsx
// ❌ BAD: Will be purged by Tailwind in production
const color = "blue";
return <span className={`pill-${color}`}>Label</span>;
// GOOD: Statically discoverable mapping
const statusPillToneClass = {
blue: "pill-blue",
green: "pill-green",
} as const;
return <span className={`pill ${statusPillToneClass[tone]}`}>Label</span>;
```
---
## 10. Migration Playbook for Future Pages
When refactoring other Control Center views, apply the following structural mapping:
### A. Settings Page
- **Old Layout**: Long scrolling list of form controls.
- **New Layout**: Split-pane layout. Left side lists settings categories (General, Hotkeys, Performance) using `.filter` tactile navigation. Right side contains the active form wrapped in a `.glass` card.
- **Inputs**: Swap default inputs to the recessed `.search` input style. Use `.btn-primary` for the final "Save" action.
### B. Plugins Page
- **Old Layout**: Flat list of available integrations.
- **New Layout**: Grid-based layout matching the `.pets-grid` structure.
- **Cards**: Use the `.pet-card` styling with a thumbnail on the left, author/description text on the right, and semantic status pills (e.g., `.pill-green` for "Active", `.pill-slate` for "Disabled").
### C. Onboarding
- **Old Layout**: Full screen wizard.
- **New Layout**: Centered, floating glass card (`.glass` with `max-w-xl shadow-glass`) layered over the atmospheric radial backdrop.
- **Interaction**: Use staggered CSS keyframes for a smooth entrance, and a prominent `.btn-primary` with active scaling for the "Get Started" call to action.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -2,11 +2,11 @@
## Responsibility
OpenPets desktop companion application. Tray-first Electron app providing animated desktop pets that react to coding agent events. Manages pet installations, plugin automation, agent integrations (Claude Code, OpenCode), and local IPC for CLI communication.
OpenPets desktop companion application. Tray-first Electron app providing animated desktop pets that react to coding agent events. Manages pet installations, the React/Tailwind Control Center, plugin automation/runtime, agent integrations (Claude Code, OpenCode, Cursor, Pi guidance), and local IPC for CLI communication.
## Design
- **Tray-First UX**: No main window; all interaction via tray menu or task windows (pet-manager, agent-setup, settings, onboarding)
- **Tray-First UX**: No default main window; tray actions open the singleton React/Tailwind Control Center and route directly to Dashboard, Pets, Integrations, Plugins, and Settings.
- **Single Instance**: Uses `app.requestSingleInstanceLock()` with second-instance focusing
- **Security Model**:
- Sandboxed renderers with contextIsolation
@ -23,11 +23,11 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
- User-configurable reaction-to-animation mapping
- **Lease Manager**: 15s TTL leases for agent pet routing with heartbeat renewal
- **Logging**: Structured logging with scopes, log rotation (2MB max), and sensitive data redaction
- **Plugin Subsystem**: Manifest-only v1 plugin runtime for timer-triggered `pet.speak`/`pet.react` actions, permission approval, config schemas, catalog/local installs, and safe path/ZIP/manifest validation
- **Plugin Subsystem**: Declarative manifest plugins and JavaScript plugin hosting with permission approval, config schemas, command/status surfaces, catalog/local installs, SDK bridge quotas, storage, schedules, restricted HTTPS fetch, and safe path/ZIP/manifest validation
## Flow
**Startup**: `main.ts``installAppLifecycle()``initializeAppState()``initializeLogger()``createAppTray()``startLocalIpcServer()` → optionally `showDefaultPet()`/`openTaskWindow("onboarding")`
**Startup**: `main.ts``installAppLifecycle()``initializeAppState()``initializeLogger()``createAppTray()``startLocalIpcServer()`initialize plugin service with JavaScript host/SDK bridge → optionally `showDefaultPet()`
**Pet Display**: IPC Request → `local-ipc.ts``LeaseManager.acquire()``agent-pet-controller.ts``pet-window.ts` → HTML/CSS spritesheet animation with reaction-to-animation mapping
@ -35,7 +35,9 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
**Agent Setup**: UI → `agent-setup.ts` → Claude/OpenCode/Cursor CLI detection → MCP config modification → hooks installation → memory file management
**Plugins**: Plugins window → `plugin-service.ts` → catalog or local manifest loader → permission approval/state update → `plugin-runtime.ts` schedules declarative timers → `plugin-pet-api.ts` applies speech/reactions to the default pet
**Control Center**: Tray route → `openControlCenterWindow(route)``windows.ts` loads Vite renderer and sends route events → `control-center-preload.cjs` exposes narrow page APIs → React Dashboard/Pets/Integrations/Plugins/Settings routes render snapshots and invoke actions.
**Plugins**: Control Center plugins route → `plugin-service.ts` → catalog or local manifest/entry loader → permission approval/state update → `plugin-runtime.ts` schedules declarative timers or starts `plugin-js-host.ts``plugin-sdk-bridge.ts` applies approved SDK calls to pet/schedule/storage/command/status/network APIs
## Integration Points
@ -60,17 +62,17 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
- `main.ts`: Entry point, lifecycle coordination
- `tray.ts`: System tray icon and menu
- `windows.ts`: Task window management (pet-manager, agent-setup, plugins, settings, onboarding)
- `plugins-window.ts`: Sandboxed plugins task-window HTML for installed/discover/developer plugin management
- `windows.ts`: Control Center BrowserWindow management, Dashboard snapshot, route targeting, IPC handlers, and internal protocols
- `renderer/`: React/Tailwind Control Center for Dashboard, Pets, Integrations, Plugins, and Settings
- `local-ipc.ts`: TCP/Unix socket server for CLI communication
- `lease-manager.ts`: Pet routing lease lifecycle
- `pet-window.ts`: Pet rendering (transparent frameless windows, CSS sprite animation, speech bubbles, status badges)
- `default-pet-controller.ts`/`agent-pet-controller.ts`: Pet visibility/state management with transient displays
- `app-state.ts`: Persistent state management (JSON file)
- `agent-setup.ts`: Claude/OpenCode/Cursor integration logic
- `plugin-service.ts`: Plugin orchestration for snapshots, enable/config/reload, catalog install/update/uninstall, local loading, permission approval, and runtime reloads
- `plugin-manifest.ts`: `openpets.plugin.json` v1 schema/types/validator for declarative timer plugins, config fields, permissions, and actions
- `plugin-runtime.ts`: Safe declarative runtime that compiles enabled plugin timers and runs approved pet speech/reaction actions
- `plugin-service.ts`: Plugin orchestration for snapshots, enable/config/reload, command execution, catalog install/update/uninstall, local loading, permission approval, JavaScript host wiring, and runtime reloads
- `plugin-manifest.ts`: `openpets.plugin.json` v1/v2 schema/types/validator for declarative timer plugins and JavaScript SDK plugins, config fields, permissions, commands/status/network, and actions
- `plugin-runtime.ts`: Runtime that compiles enabled declarative timers and starts JavaScript plugin hosts for approved pet/schedule/storage/command/status/network actions
- `plugin-state.ts`: Atomic JSON state store for installed plugins, enabled flag, approved permissions, config, broken state, and update metadata
- `plugin-config.ts`: Plugin default/effective config validation and config reference resolution
- `plugin-catalog.ts`/`plugin-catalog-validation.ts`: Plugin catalog fetch/cache and strict catalog entry validation
@ -78,13 +80,15 @@ OpenPets desktop companion application. Tray-first Electron app providing animat
- `plugin-local-loader.ts`: Local developer plugin folder validation and manifest snapshotting into app data
- `plugin-manifest-reader.ts`: Safe installed-manifest reader enforcing allowed roots, size limits, path containment, and expected id/version
- `plugin-pet-api.ts`: Runtime bridge from plugin actions to default pet speech/reaction APIs
- `plugin-js-host.ts`: Hidden sandboxed BrowserWindow host for JavaScript plugin entry modules, SDK IPC tokening, session hardening, startup handshake, and teardown
- `plugin-sdk-bridge.ts`: Permission-checked SDK API for JavaScript plugins with quotas, plugin storage, schedules, config listeners, commands/status, logs, and restricted HTTPS fetch
- `pet-installation.ts`: Catalog ZIP download and extraction
- `codex-pets.ts`: Local Codex pet import
- `catalog.ts`: Remote catalog fetching with V3 pagination and fixture fallback
- `logger.ts`: Structured logging with scopes (app, ipc, lease, pet, state, tray, ui)
- `reaction-animation-mapping.ts`: Reaction-to-animation state mapping with user overrides
- `reaction-messages.ts`: Message pools for each reaction type
- `preload.cjs`/`pet-preload.cjs`: Renderer preload scripts (contextBridge APIs)
- `control-center-preload.cjs`/`pet-preload.cjs`/`plugin-sdk-preload.cjs`: Narrow contextBridge APIs for the Control Center, pet windows, and plugin SDK host; the legacy `preload.cjs` task-window bridge and `plugins-window.ts` UI have been removed
- `electron-builder.yml`: Packaging configuration
- `scripts/release-local.mjs`: macOS-local release automation with GitHub draft creation
- `contracts/catalog-fixture.contract.ts`: Catalog V2 validation contract tests against fixture data

View file

@ -0,0 +1,43 @@
const { contextBridge, ipcRenderer } = require("electron");
const api = {
getPetsState: () => ipcRenderer.invoke("openpets:get-pets-state"),
getDashboardSnapshot: () => ipcRenderer.invoke("openpets:get-dashboard-snapshot"),
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"),
getPluginsSnapshot: () => ipcRenderer.invoke("openpets:plugins-snapshot"),
getPluginCatalogSnapshot: (refresh) => ipcRenderer.invoke("openpets:plugins-catalog-snapshot", refresh),
setPluginEnabled: (id, enabled) => ipcRenderer.invoke("openpets:plugins-set-enabled", id, enabled),
savePluginConfig: (id, config) => ipcRenderer.invoke("openpets:plugins-save-config", id, config),
reloadPlugin: (id) => ipcRenderer.invoke("openpets:plugins-reload", id),
executePluginCommand: (id, commandId) => ipcRenderer.invoke("openpets:plugins-execute-command", id, commandId),
loadLocalPlugin: () => ipcRenderer.invoke("openpets:plugins-load-local"),
installCatalogPlugin: (id) => ipcRenderer.invoke("openpets:plugins-install-catalog", id),
updateCatalogPlugin: (id) => ipcRenderer.invoke("openpets:plugins-update-catalog", id),
uninstallPlugin: (id) => ipcRenderer.invoke("openpets:plugins-uninstall", id),
getCatalog: () => ipcRenderer.invoke("openpets:get-catalog"),
getCatalogPage: (page) => ipcRenderer.invoke("openpets:get-catalog-page", page),
getCatalogSearch: () => ipcRenderer.invoke("openpets:get-catalog-search"),
getCodexPets: () => ipcRenderer.invoke("openpets:get-codex-pets"),
setDefaultPet: (petId) => ipcRenderer.invoke("openpets:set-default-pet", petId),
installPet: (petId) => ipcRenderer.invoke("openpets:install-pet", petId),
importCodexPet: (petId) => ipcRenderer.invoke("openpets:import-codex-pet", petId),
removePet: (petId) => ipcRenderer.invoke("openpets:remove-pet", petId),
onRouteChange: (callback) => {
const listener = (_event, route) => callback(route);
ipcRenderer.on("openpets:control-center-route", listener);
return () => ipcRenderer.removeListener("openpets:control-center-route", listener);
},
getIntegrationsState: (selectedPetId, commandMode) => ipcRenderer.invoke("openpets:agent-setup-snapshot", selectedPetId, commandMode),
runIntegrationAction: (action, selectedPetId, commandMode) => ipcRenderer.invoke("openpets:agent-setup-action", action, selectedPetId, commandMode),
updateIntegrationCommandPaths: (patch) => ipcRenderer.invoke("openpets:agent-setup-command-paths", patch),
};
contextBridge.exposeInMainWorld("openPetsControlCenter", api);

View file

@ -10,7 +10,7 @@ artifactName: OpenPets-${version}-${os}-${arch}.${ext}
files:
- dist/**
- preload.cjs
- control-center-preload.cjs
- pet-preload.cjs
- plugin-sdk-preload.cjs
- assets/**

View file

@ -14,6 +14,8 @@
"main": "dist/main.js",
"scripts": {
"dev": "pnpm build && electron .",
"dev:electron": "pnpm build:main && electron .",
"dev:control-center": "concurrently -k -n renderer,electron -c cyan,magenta \"pnpm dev:renderer\" \"wait-on http://127.0.0.1:5173 && cross-env OPENPETS_RENDERER_URL=http://127.0.0.1:5173 pnpm dev:electron\"",
"dev:plugins": "OPENPETS_DISABLE_PLUGIN_CATALOG=1 OPENPETS_DEV_PLUGIN_ROOTS=../../web/plugins/official pnpm dev",
"dev:debug": "OPENPETS_LOG_LEVEL=debug OPENPETS_LOG_CONSOLE=1 pnpm dev",
"package": "pnpm build && node scripts/clean-package-output.cjs && electron-builder",
@ -22,15 +24,28 @@
"build:deps": "pnpm --filter @open-pets/desktop^... build",
"test:build": "pnpm build:deps && tsc -p tsconfig.tests.json",
"check": "pnpm typecheck && pnpm build && pnpm test",
"typecheck": "pnpm build:deps && tsc --noEmit",
"build": "pnpm build:deps && tsc"
"typecheck": "pnpm build:deps && tsc --noEmit && tsc -p tsconfig.renderer.json --noEmit",
"build:main": "pnpm build:deps && tsc",
"build:renderer": "vite build",
"dev:renderer": "vite --host 127.0.0.1",
"build": "pnpm build:main && pnpm build:renderer"
},
"devDependencies": {
"@types/node": "^25.6.2",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/yauzl": "2.10.3",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^42.0.0",
"electron-builder": "^26.10.0",
"typescript": "^6.0.3"
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"vite": "^8.0.14",
"wait-on": "^9.0.10"
},
"dependencies": {
"@open-pets/agent-events": "workspace:*",
@ -39,6 +54,8 @@
"@open-pets/cursor": "workspace:*",
"@open-pets/mcp": "workspace:*",
"@open-pets/opencode": "workspace:*",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"sharp": "^0.34.5",
"yauzl": "3.3.0"
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, "..");
const preloadChecks = ["preload.cjs", "pet-preload.cjs", "plugin-sdk-preload.cjs"];
const preloadChecks = ["control-center-preload.cjs", "pet-preload.cjs", "plugin-sdk-preload.cjs"];
const behaviorTests = [
".test-dist/tests/lease-manager.test.js",
".test-dist/tests/onboarding-state.test.js",
@ -26,7 +26,6 @@ const behaviorTests = [
".test-dist/tests/plugin-catalog-validation.test.js",
".test-dist/tests/plugin-package.test.js",
".test-dist/tests/plugin-service.test.js",
".test-dist/tests/plugins-window.test.js",
".test-dist/tests/plugin-ui-static.test.js",
];
const contractTests = [

View file

@ -351,7 +351,7 @@ async function getOpenCodeSetup(commandMode: OpenPetsCommandMode, selectedPetId:
label: configured ? "Installed" : globalState.status === "custom" || globalState.status === "conflict" ? "Needs attention" : detected.ok ? "Ready" : "Not detected",
details: globalState.status === "custom" || globalState.status === "conflict" || globalState.status === "error" ? globalState.message : configured ? globalState.message : detected.ok ? "OpenCode was detected. Desktop setup writes global OpenCode config." : getPreferredOpenCodeCommand() === (process.platform === "win32" ? "opencode.cmd" : "opencode") ? "OpenCode was not found on PATH. You can still preview setup, but OpenCode must be installed to use it." : "OpenCode did not run from the saved command path. You can still preview setup, but OpenCode must be installed to use it.",
configDir: formatUserPath(configDir) ?? configDir,
canInstall: prepared.ok,
canInstall: prepared.ok && !configured,
canRemove: configured,
},
preview: {

View file

@ -6,6 +6,7 @@ import { app } from "electron";
import { defaultPetScale, markOnboardingCompleted, normalizeOnboardingCompleted, normalizePetScale, petScaleOptions, type PetScaleValue } from "./app-state-core.js";
import { builtInPet } from "./built-in-pet.js";
import type { Point } from "./display.js";
import { allowedReactions, type OpenPetsReaction } from "./local-ipc-protocol.js";
import { assertSafePetId, getInstalledPetDir } from "./pet-paths.js";
import { normalizeReactionAnimationOverrides, type ReactionAnimationOverrides } from "./reaction-animation-mapping.js";
@ -48,8 +49,21 @@ export interface OpenPetsStateV1 {
readonly defaultPet: {
readonly position?: Point;
};
readonly analytics: OpenPetsAnalyticsState;
}
export interface OpenPetsAnalyticsState {
readonly messagesSent: number;
readonly reactionsSent: number;
readonly reactionCounts: Record<OpenPetsReaction, number>;
readonly perPetActivityCounts: Record<string, number>;
readonly lastActivityAt?: number;
}
export type OpenPetsActivityRecord =
| { readonly kind: "say"; readonly reaction?: OpenPetsReaction; readonly petId?: string }
| { readonly kind: "react"; readonly reaction: OpenPetsReaction; readonly petId?: string };
export { defaultPetScale, normalizePetScale, petScaleOptions, type PetScaleValue };
const stateFileName = "openpets-state.json";
@ -151,6 +165,30 @@ export function getDefaultPetPosition(): Point | undefined {
return getInitializedState().defaultPet.position;
}
export function recordOpenPetsActivity(activity: OpenPetsActivityRecord, now: number = Date.now()): OpenPetsStateV1 {
const state = getInitializedState();
const analytics = state.analytics;
const reaction = activity.kind === "react" ? activity.reaction : activity.reaction;
const petId = activity.petId;
const nextState = normalizeState({
...state,
analytics: {
messagesSent: analytics.messagesSent + (activity.kind === "say" ? 1 : 0),
reactionsSent: analytics.reactionsSent + (reaction ? 1 : 0),
reactionCounts: reaction
? { ...analytics.reactionCounts, [reaction]: (analytics.reactionCounts[reaction] ?? 0) + 1 }
: analytics.reactionCounts,
perPetActivityCounts: petId
? { ...analytics.perPetActivityCounts, [petId]: (analytics.perPetActivityCounts[petId] ?? 0) + 1 }
: analytics.perPetActivityCounts,
lastActivityAt: normalizeTimestamp(now) ?? Date.now(),
},
});
commitState(nextState);
return getAppStateSnapshot();
}
export function installPetState(pet: Omit<InstalledPetState, "builtIn" | "protected" | "installed">): OpenPetsStateV1 {
const state = getInitializedState();
@ -280,9 +318,55 @@ function normalizeState(value: unknown): OpenPetsStateV1 {
installed: installedPets,
},
defaultPet: position ? { position } : {},
analytics: normalizeAnalytics(record.analytics),
};
}
function normalizeAnalytics(value: unknown): OpenPetsAnalyticsState {
const record = isRecord(value) ? value : {};
return {
messagesSent: normalizeCount(record.messagesSent),
reactionsSent: normalizeCount(record.reactionsSent),
reactionCounts: normalizeReactionCounts(record.reactionCounts),
perPetActivityCounts: normalizePerPetActivityCounts(record.perPetActivityCounts),
lastActivityAt: normalizeTimestamp(record.lastActivityAt),
};
}
function normalizeReactionCounts(value: unknown): Record<OpenPetsReaction, number> {
const record = isRecord(value) ? value : {};
const counts = {} as Record<OpenPetsReaction, number>;
for (const reaction of allowedReactions) {
counts[reaction] = normalizeCount(record[reaction]);
}
return counts;
}
function normalizePerPetActivityCounts(value: unknown): Record<string, number> {
const record = isRecord(value) ? value : {};
const counts: Record<string, number> = {};
for (const [petId, rawCount] of Object.entries(record)) {
if (petId !== builtInPet.id) {
try {
assertSafePetId(petId);
} catch {
continue;
}
}
const count = normalizeCount(rawCount);
if (count > 0) counts[petId] = count;
}
return counts;
}
function normalizeCount(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
}
function normalizeTimestamp(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
}
function normalizePreferences(value: Partial<OpenPetsStateV1["preferences"]>): OpenPetsStateV1["preferences"] {
const defaultState = createDefaultState();
@ -370,6 +454,13 @@ function createDefaultState(): OpenPetsStateV1 {
installed: [builtInPet],
},
defaultPet: {},
analytics: {
messagesSent: 0,
reactionsSent: 0,
reactionCounts: normalizeReactionCounts(undefined),
perPetActivityCounts: {},
lastActivityAt: undefined,
},
};
}

View file

@ -45,13 +45,13 @@ assert.match(builderConfig, /asar:\s*true/);
assert.match(builderConfig, /asarUnpack:/);
assert.match(builderConfig, /node_modules\/\*\*/);
assert.match(builderConfig, /dist\/\*\*/);
assert.match(builderConfig, /preload\.cjs/);
assert.match(builderConfig, /control-center-preload\.cjs/);
assert.match(builderConfig, /pet-preload\.cjs/);
assert.match(builderConfig, /plugin-sdk-preload\.cjs/);
assert.match(builderConfig, /assets\/\*\*/);
assert.match(builderConfig, /icon:\s*assets\/app-icon\.icns/);
assert.ok(existsSync(join(appDir, "preload.cjs")), "preload.cjs must exist for packaging.");
assert.ok(existsSync(join(appDir, "control-center-preload.cjs")), "control-center-preload.cjs must exist for Control Center IPC.");
assert.ok(existsSync(join(appDir, "pet-preload.cjs")), "pet-preload.cjs must exist for pet window motion state updates.");
assert.ok(existsSync(join(appDir, "plugin-sdk-preload.cjs")), "plugin-sdk-preload.cjs must exist for JavaScript plugin SDK hosting.");
assert.ok(existsSync(join(appDir, "assets", "tray-icon.png")), "tray icon must exist for packaging.");
@ -59,13 +59,13 @@ assert.ok(existsSync(join(appDir, "assets", "app-icon.icns")), "app icon must ex
assert.ok(existsSync(join(appDir, "assets", "app-icon.ico")), "Windows app icon must exist for packaging.");
assertNonEmptyFile(join(appDir, "assets", "default-pet-spritesheet.webp"), "default pet spritesheet must exist for packaging.");
assertNonEmptyFile(join(appDir, "assets", "default-pet-thumbnail.png"), "default pet thumbnail must exist for Pet Manager preview.");
assertNonEmptyFile(join(appDir, "assets", "onboarding-logo.webp"), "onboarding logo asset must exist for packaging.");
assertNonEmptyFile(join(appDir, "assets", "onboarding-pets.webp"), "onboarding pet scene asset must exist for packaging.");
for (const icon of ["claude.svg", "cursor.svg", "opencode.svg", "pi.svg", "vscode.svg", "windsurf.svg", "zed.svg"]) {
assertSafeBundledSvg(join(appDir, "assets", "integrations", icon), `integration icon must be safe and packaged: ${icon}`);
}
assert.match(readFileSync(join(appDir, "src", "assets.ts"), "utf8"), /assets["']?,\s*["']tray-icon\.png|join\("assets", "tray-icon\.png"\)/, "tray icon code must keep using assets/tray-icon.png.");
const petWindowSource = readFileSync(join(appDir, "src", "pet-window.ts"), "utf8");
const controlCenterPreloadSource = readFileSync(join(appDir, "control-center-preload.cjs"), "utf8");
const controlCenterRendererSource = readFileSync(join(appDir, "src", "renderer", "src", "main.tsx"), "utf8");
const petPreloadSource = readFileSync(join(appDir, "pet-preload.cjs"), "utf8");
const reactionMessagesSource = readFileSync(join(appDir, "src", "reaction-messages.ts"), "utf8");
const displaySource = readFileSync(join(appDir, "src", "display.ts"), "utf8");
@ -73,7 +73,6 @@ const updateCheckerSource = readFileSync(join(appDir, "src", "update-checker.ts"
const traySource = readFileSync(join(appDir, "src", "tray.ts"), "utf8");
const windowsSource = readFileSync(join(appDir, "src", "windows.ts"), "utf8");
const agentSetupSource = readFileSync(join(appDir, "src", "agent-setup.ts"), "utf8");
const preloadSource = readFileSync(join(appDir, "preload.cjs"), "utf8");
const loggerSource = readFileSync(join(appDir, "src", "logger.ts"), "utf8");
const mainSource = readFileSync(join(appDir, "src", "main.ts"), "utf8");
const localIpcSourceForLogging = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8");
@ -173,26 +172,11 @@ assert.match(windowsSource, /openpets:get-reaction-animation-settings/, "setting
assert.match(windowsSource, /reactionAnimationOverrides/, "settings window must be able to persist reaction animation overrides.");
assert.match(windowsSource, /openpets-pet-preview/, "settings reaction preview must use a scoped internal pet preview protocol.");
assert.match(windowsSource, /openpets:open-update-release-page/, "settings window must be able to open the release page.");
assert.match(windowsSource, /id="check-for-updates"/, "settings UI must include a Check for updates button.");
assert.match(windowsSource, /id="open-update-release"/, "settings UI must include an Open release button.");
assert.match(preloadSource, /checkForUpdates/, "settings preload must expose update checks.");
assert.match(preloadSource, /getReactionAnimationSettings/, "settings preload must expose reaction animation settings metadata.");
assert.match(preloadSource, /reaction-animation-table/, "settings preload must render the reaction animation mapping table.");
assert.match(preloadSource, /\{ label: "Thinking", state: "thinking" \}/, "Pet Manager mini previews must use a non-idle thinking/review state because the hero already shows idle.");
assert.match(preloadSource, /\{ label: "Happy", state: "happy" \}/, "Pet Manager mini previews must request the happy/jumping row.");
assert.match(preloadSource, /\{ label: "Wave", state: "wave" \}/, "Pet Manager mini previews must request the waving row.");
assert.doesNotMatch(preloadSource, /\{ label: "Idle", state: "idle" \}/, "Pet Manager mini previews must not duplicate the hero idle preview.");
assert.match(preloadSource, /pm-mini-sprite[\s\S]*animated: true/, "Pet Manager mini state previews must animate spritesheet states.");
assert.match(preloadSource, /pm-sprite-state-\$\{state\}/, "Pet Manager sprite frames must apply state-specific row classes.");
assert.match(windowsSource, /pm-sprite-state-thinking \{ background-position: 0 100%; \}/, "Pet Manager thinking preview must use universal spritesheet row 8.");
assert.match(windowsSource, /pm-sprite-state-wave \{ background-position: 0 37\.5%; \}/, "Pet Manager wave preview must use universal spritesheet row 3.");
assert.match(windowsSource, /pm-sprite-state-happy \{ background-position: 0 50%; \}/, "Pet Manager happy preview must use universal spritesheet row 4.");
assert.match(windowsSource, /pm-sprite-state-thinking\.pm-animate-sprite \{ animation: pm-sprite-thinking 1\.55s steps\(6\) infinite; \}/, "Pet Manager thinking preview must animate the 6-frame review row at a calm speed.");
assert.match(windowsSource, /pm-sprite-state-wave\.pm-animate-sprite \{ animation: pm-sprite-wave 1\.25s steps\(4\) infinite; \}/, "Pet Manager wave preview must animate the 4-frame waving row at a calm speed.");
assert.match(windowsSource, /pm-sprite-state-happy\.pm-animate-sprite \{ animation: pm-sprite-happy 1\.35s steps\(5\) infinite; \}/, "Pet Manager happy preview must animate the 5-frame jumping row at a calm speed.");
assert.match(windowsSource, /@keyframes pm-sprite-thinking/, "Pet Manager must define thinking row animation keyframes.");
assert.match(windowsSource, /@keyframes pm-sprite-wave/, "Pet Manager must define wave row animation keyframes.");
assert.match(windowsSource, /@keyframes pm-sprite-happy/, "Pet Manager must define happy row animation keyframes.");
assert.match(controlCenterPreloadSource, /checkForUpdates/, "Control Center preload must expose update checks.");
assert.match(controlCenterPreloadSource, /getReactionAnimationSettings/, "Control Center preload must expose reaction animation settings metadata.");
assert.match(controlCenterRendererSource, /function SettingsView\(\)/, "Control Center must include the settings page.");
assert.match(controlCenterRendererSource, /getPetsState/, "Control Center must include the pets page data bridge.");
assert.match(controlCenterRendererSource, /function IntegrationsView\(\)/, "Control Center must include the integrations page.");
assert.match(petWindowSource, /max-width:\s*min\(220px/, "very long message bubbles must stay capped within the tight pet window.");
assert.match(petWindowSource, /-webkit-line-clamp:\s*8/, "very long message bubbles must allow enough visible lines.");
assert.match(petWindowSource, /createSpriteStateCss\("\.sprite"\)/, "built-in sprite CSS must react to reaction state.");
@ -220,49 +204,13 @@ for (const reaction of allowedReactions) {
}
}
assert.equal(pickReactionMessage("success", () => 0), reactionMessagePools.success[0], "reaction message picking must be deterministic when random is injected.");
const agentSetupHtmlSource = windowsSource.match(/function createAgentSetupHtml[\s\S]*?function createSettingsHtml/)?.[0] ?? "";
assert.match(windowsSource, /onboarding-logo\.webp/, "onboarding greeting must reference the bundled OpenPets logo asset.");
assert.match(windowsSource, /onboarding-pets\.webp/, "onboarding greeting must reference the bundled pet scene asset.");
assert.ok(windowsSource.includes(`content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"`), "onboarding image CSP must stay data-only for embedded packaged assets.");
assert.doesNotMatch(windowsSource, /data-default-pet-sprite-src|createAssetDataUrl\("default-pet-spritesheet\.webp"/, "Pet Manager must not embed the large default spritesheet into the task-window data URL.");
assert.match(windowsSource, /default-pet-thumbnail\.png/, "Pet Manager must use the small bundled default pet thumbnail for built-in preview.");
assert.ok(windowsSource.includes(`content="default-src 'none'; img-src data: https://openpets.dev openpets-codex:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"`), "Pet Manager image CSP must stay scoped to data URLs, openpets.dev catalog previews, and the Codex spritesheet protocol.");
assert.match(windowsSource, /petManagerWindowWidth\s*=\s*1160/, "Pet Manager should use the approved wider 1160px default window width.");
assert.match(windowsSource, /petManagerWindowHeight\s*=\s*780/, "Pet Manager should use the approved taller 780px default window height.");
assert.match(agentSetupHtmlSource, /content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"/, "Agent Setup image CSP must stay data-only for the bundled logo asset.");
assert.match(windowsSource, /integrations\/claude\.svg/, "Agent Setup integrations hub must use bundled integration SVG assets.");
assert.match(windowsSource, /integration-opencode-status/, "Agent Setup must show an enabled OpenCode integration card.");
assert.match(windowsSource, /opencode-detail-view/, "Agent Setup must include an OpenCode detail pane.");
assert.match(windowsSource, /integrations\/pi\.svg/, "Agent Setup integrations hub must use the bundled Pi SVG asset.");
assert.match(windowsSource, /integration-pi-status/, "Agent Setup must show a Pi integration card.");
assert.match(windowsSource, /pi-detail-view/, "Agent Setup must include a Pi manual setup detail pane.");
assert.match(windowsSource, /Desktop OpenCode setup is global/, "OpenCode desktop setup must clearly warn that it is global.");
assert.match(preloadSource, /opencode-install/, "Agent Setup preload must bind OpenCode install actions.");
assert.match(preloadSource, /setIconButtonContent\(opencodeInstall, "spinner", "Installing…"\)/, "OpenCode integration-card install button must show a loading spinner.");
assert.match(preloadSource, /opencode-remove/, "Agent Setup preload must bind OpenCode remove actions.");
assert.match(preloadSource, /opencode-copy-config/, "Agent Setup preload must bind OpenCode copy-preview actions.");
assert.match(preloadSource, /opencodePreview/, "Agent Setup preload must render OpenCode previews.");
assert.match(preloadSource, /integration-pi-configure/, "Agent Setup preload must bind the Pi integration card.");
assert.match(preloadSource, /pi-copy-global-install/, "Agent Setup preload must bind Pi copy commands.");
assert.match(preloadSource, /cleanupConfigPaths/, "OpenCode preview must disclose stale overlay cleanup config paths.");
assert.match(preloadSource, /configPreview/, "OpenCode preview copy must use the prepared config preview shape.");
assert.match(agentSetupHtmlSource, /integration-cursor-status/, "Agent Setup must show an enabled Cursor integration card.");
assert.match(agentSetupHtmlSource, /cursor-detail-view/, "Agent Setup must include a Cursor detail pane.");
assert.match(preloadSource, /cursor-install/, "Agent Setup preload must bind Cursor install actions.");
assert.match(preloadSource, /cursor-replace/, "Agent Setup preload must bind Cursor replace actions.");
assert.match(preloadSource, /cursor-remove/, "Agent Setup preload must bind Cursor remove actions.");
assert.match(preloadSource, /cursor-copy-preview/, "Agent Setup preload must bind Cursor copy-preview actions.");
assert.match(agentSetupHtmlSource, /cursor-rules-preview/, "Agent Setup must show a Cursor project rules preview.");
assert.match(agentSetupHtmlSource, /Desktop does not write project rules/, "Desktop Cursor rules UI must be preview/copy only.");
assert.match(preloadSource, /cursor-copy-rules/, "Agent Setup preload must bind Cursor rules copy actions.");
assert.doesNotMatch(agentSetupSource, /planCursorRules|executeCursorRules|cursor-rules-install|cursor-rules-remove/, "Desktop must not write Cursor project rules without a reviewed project picker.");
assert.match(preloadSource, /updateCursorIntegration/, "Agent Setup preload must render Cursor integration.");
assert.match(preloadSource, /cursorStatus/, "Agent Setup preload must check Cursor status.");
assert.match(preloadSource, /cursorPreview/, "Agent Setup preload must render Cursor preview.");
assert.doesNotMatch(controlCenterRendererSource, /OnboardingView|getOnboardingSnapshot|completeOnboarding/, "Control Center must not include the removed onboarding route.");
assert.match(controlCenterRendererSource, /function IntegrationsView\(\)/, "Control Center must include integrations.");
assert.match(controlCenterRendererSource, /Claude Code/, "Control Center integrations must include Claude Code.");
assert.match(controlCenterRendererSource, /OpenCode/, "Control Center integrations must include OpenCode.");
assert.match(controlCenterRendererSource, /Cursor/, "Control Center integrations must include Cursor.");
assert.match(controlCenterRendererSource, /Pi/, "Control Center integrations must include Pi.");
assert.doesNotMatch(agentSetupSource, /JSON\.parse\(prepared\.configWrite\.content\)/, "OpenCode desktop preview must parse JSONC planned config safely, not JSON.parse.");
assert.doesNotMatch(agentSetupHtmlSource, /https?:\/\//, "Agent Setup must not allow or reference remote integration assets.");
assert.match(windowsSource, /agentSetupWindowWidth\s*=\s*1160/, "Agent Setup should use the approved wider 1160px default window width.");
assert.match(windowsSource, /agentSetupWindowHeight\s*=\s*780/, "Agent Setup should use the approved taller 780px default window height.");
assert.match(windowsSource, /refreshDefaultPetContent\(\);\s*refreshAgentPetContent\(\);/, "pet scale preference changes must refresh default and agent pet windows.");
assert.ok(existsSync(join(appDir, "scripts", "clean-package-output.cjs")), "package output cleanup helper must exist.");
assert.ok(existsSync(join(distDir, "main.js")), "desktop main build output must exist before packaging checks run.");

View file

@ -2,7 +2,7 @@
## Responsibility
Core TypeScript source for the OpenPets desktop application. Organized into: lifecycle management, state persistence, UI windows, pet rendering, IPC server, agent integrations, pet installation/management, and the manifest-only plugin subsystem.
Core TypeScript source for the OpenPets desktop application. Organized into: lifecycle management, state persistence, Control Center and pet windows, IPC server, agent integrations, pet installation/management, and declarative plus JavaScript plugin runtimes.
## Design
@ -11,10 +11,10 @@ Core TypeScript source for the OpenPets desktop application. Organized into: lif
- **Defensive I/O**: All file operations use temp+rename for atomicity, path traversal validation, symlink checks
- **Validation at Boundaries**: Catalog, ZIP entries, pet metadata, and IPC params all strictly validated
- **Lease Pattern**: Agent pets use expiring leases (15s TTL) with heartbeats; default pet is persistent
- **Sandboxed HTML**: All UI is data-URL or file-URL HTML with inline CSS, no external resources
- **Sandboxed Renderers**: Control Center loads the Vite React/Tailwind bundle through a hardened BrowserWindow and narrow preload bridge; transparent pet windows and plugin SDK host windows stay separate
- **Structured Logging**: Scoped logging (app, ipc, lease, pet.*, state, tray, ui) with log rotation and redaction
- **Reaction Animation Mapping**: User-configurable mapping from reaction types to sprite animation states
- **Manifest-Only Plugins**: Plugins are declarative `openpets.plugin.json` manifests with approved permissions, persisted config, safe path checks, and timer-triggered pet actions; JavaScript runtime is recognized but unsupported.
- **Plugin Runtimes**: Plugins use validated manifests, approved permissions, persisted config, safe path checks, declarative timer-triggered actions, or sandboxed JavaScript entry modules through the SDK bridge.
## Flow
@ -24,7 +24,7 @@ main.ts
├── lifecycle.ts (app events, cleanup)
├── logger.ts (structured logging init)
├── app-state.ts (state init)
├── plugin-service.ts (plugin state/runtime init)
├── plugin-service.ts (plugin state/runtime init, JS host wiring)
├── tray.ts (tray creation)
├── local-ipc.ts (IPC server start)
└── windows.ts (UI handlers)
@ -78,17 +78,28 @@ pet-installation.ts
└── importCodexPet() → codex-pets.ts
```
**Control Center Flow**:
```
tray.ts → openControlCenterWindow(route) → windows.ts
├── hardened BrowserWindow loads Vite renderer or packaged dist/renderer/index.html
├── control-center-preload.cjs exposes page-specific APIs
├── Dashboard snapshot: default pet, catalog, plugin health, update status, activity
└── renderer/src/main.tsx routes Dashboard/Pets/Integrations/Plugins/Settings
```
**Plugin Flow**:
```
main.ts → initializePluginService(userData, defaultPluginPetApi).start()
main.ts → initializePluginService(userData, defaultPluginPetApi, appVersion, ElectronPluginJsHost).start()
├── plugin-state.ts reads/writes userData/openpets-plugin-state.json
├── plugin-runtime.ts reloads enabled manifests and schedules timer triggers
│ └── plugin-pet-api.ts → default-pet-controller external say/react APIs
├── plugin-runtime.ts reloads enabled manifests
│ ├── declarative runtime schedules timer triggers
│ ├── plugin-js-host.ts starts hidden sandboxed BrowserWindow hosts for JavaScript plugins
│ └── plugin-sdk-bridge.ts → plugin-pet-api.ts/default-pet-controller plus schedules, storage, commands, status, logs, and restricted network
├── plugin-service.ts orchestrates UI actions, permission confirmation, config validation, install/update/uninstall/load-local, and runtime reloads
└── lifecycle.ts → stopPluginService() on quit
Plugins window:
tray.ts → openTaskWindow("plugins") → windows.ts → plugins-window.ts
Control Center plugins route:
tray.ts → openControlCenterWindow("plugins") → windows.ts → renderer React app
└── openpets:plugins-* IPC handlers call PluginService methods
Catalog install/update:
@ -102,13 +113,13 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only ope
## Integration Points
- **Within src/**:
- `main.ts` → all modules (orchestrator)
- `main.ts` → all modules (orchestrator), including `ElectronPluginJsHost` for JavaScript plugins
- `local-ipc.ts``lease-manager.ts``agent-pet-controller.ts`
- `windows.ts``app-state.ts`, `agent-setup.ts`, `catalog.ts`, `codex-pets.ts`
- `windows.ts``plugin-service.ts`, `plugins-window.ts` for plugin UI IPC and HTML
- `windows.ts``app-state.ts`, `agent-setup.ts`, `catalog.ts`, `codex-pets.ts`, `update-checker.ts` for Control Center route snapshots/actions
- `windows.ts``plugin-service.ts` for Control Center plugin UI IPC, plugin commands, and Dashboard plugin health
- `pet-window.ts``default-pet-controller.ts`, `agent-pet-controller.ts`
- `pet-installation.ts``app-state.ts`, `catalog.ts`, `zip-safety.ts`
- `plugin-service.ts``plugin-state.ts`, `plugin-runtime.ts`, `plugin-catalog.ts`, `plugin-package.ts`, `plugin-local-loader.ts`
- `plugin-service.ts``plugin-state.ts`, `plugin-runtime.ts`, `plugin-catalog.ts`, `plugin-package.ts`, `plugin-local-loader.ts`, `plugin-js-host.ts`, `plugin-sdk-bridge.ts`
- **To packages/**:
- `@open-pets/claude`: `buildClaudeMcpPreview`, `installClaudeHooks`, `doctorClaudeHooks`, etc.
@ -117,14 +128,14 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only ope
- `@open-pets/cli`: Version lookup for bundled mode
- **To System**:
- File system: `app.getPath("userData")`, `userData/plugins/`, `userData/plugins-dev/`, `~/.codex/pets/`, `~/.claude/`, `~/.opencode/`
- File system: `app.getPath("userData")`, `userData/plugins/`, `userData/plugins-dev/`, plugin storage JSON, `~/.codex/pets/`, `~/.claude/`, `~/.opencode/`
- Network: `fetch()` to openpets.dev, GitHub API, plugin catalog at `https://openpets.dev/plugins/catalog.v1.json`, plugin ZIPs restricted to `https://zip.openpets.dev/plugins/`
- Processes: `spawn()` for `claude`, `opencode`, `node`
## Key Modules
**Core**:
- `main.ts`: Entry, single-instance lock, bootstrap sequence
- `main.ts`: Entry, single-instance lock, bootstrap sequence, JavaScript plugin host construction
- `lifecycle.ts`: App event handlers (quit, window-all-closed, second-instance) with logging; stops plugin service, IPC, and pet windows on quit
- `state.ts`: Simple shell pause state
- `app-state.ts`: Persistent JSON state with V1 schema, atomic writes, reaction animation overrides
@ -132,11 +143,11 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only ope
- `logger.ts`: Structured logging with scopes (app, ipc, lease, pet.default, pet.agent, pet.window, state, tray, ui), log rotation, redaction
**UI**:
- `tray.ts`: Tray icon (nativeImage), context menu builder, update status integration, plugins entry, logs folder
- `windows.ts`: BrowserWindow factory, IPC handler registration, HTML generators for task windows, reaction animation settings, plugin UI IPC endpoints
- `tray.ts`: Tray icon (nativeImage), context menu builder, update status integration, route-targeted Control Center entries, logs folder
- `windows.ts`: Control Center BrowserWindow factory, Dashboard snapshot, IPC handler registration, route targeting, reaction animation settings, plugin/integration/pet/settings UI IPC endpoints, and scoped internal protocols
- `assets.ts`: Tray icon loading with generated fallback
- `display.ts`: Screen geometry helpers, pet window positioning
- `plugins-window.ts`: Sandboxed data-URL HTML/CSS shell for Installed/Discover/Developer plugin management.
- `renderer/`: Vite React/Tailwind Control Center shell for Dashboard, Pets, Integrations, Plugins, and Settings.
**Pets**:
- `pet-window.ts`: Window creation (transparent, frameless, always-on-top), HTML/CSS generation, sprite animation states, speech bubbles, status badges, transient displays
@ -162,17 +173,19 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only ope
- `zip-safety.ts`: ZIP entry path validation (traversal prevention, case collision detection)
**Plugins**:
- `plugin-manifest.ts`: Manifest V1 schema/types and validation for declarative runtime, permissions (`timer`, `pet:speak`, `pet:reaction`), config schema, timer triggers, and pet actions.
- `plugin-manifest.ts`: Manifest V1/V2 schema/types and validation for declarative and JavaScript runtimes, permissions (`timer`/`schedule`, `pet:speak`, `pet:reaction`, `storage`, `status`, `commands`, `network`), config schema, timer triggers, entry files, and pet actions.
- `plugin-manifest-reader.ts`: Safe manifest reader with realpath/allowed-root checks, root filename enforcement, size limit, and expected id/version matching.
- `plugin-config.ts`: Config defaulting, replacement validation, and runtime resolution for string/number config references.
- `plugin-state.ts`: Persistent plugin state store (`openpets-plugin-state.json`) with atomic temp+rename writes, normalized records, approved permissions, config, source, and broken reason.
- `plugin-runtime.ts`: Declarative runtime that compiles enabled plugin timer triggers, verifies approved permissions, validates say/react payloads, schedules cancellable timers, and marks broken plugins on validation/action failure.
- `plugin-runtime.ts`: Runtime that compiles enabled declarative timer triggers, starts/stops JavaScript plugin hosts, verifies approved permissions, exposes public command/status state, validates actions, schedules cancellable timers, and marks broken plugins on validation/action failure.
- `plugin-pet-api.ts`: Narrow adapter from plugin actions to default pet external `say`/`react` controller calls.
- `plugin-service.ts`: Application-facing plugin orchestrator for snapshots, enable/disable, config save, reload, catalog install/update, local load, uninstall, permission prompts, compatibility checks, and runtime reloads.
- `plugin-service.ts`: Application-facing plugin orchestrator for safe snapshots, enable/disable, config save, command execution, reload, catalog install/update, local load, uninstall, permission prompts, compatibility checks, JavaScript host/SDK bridge integration, and runtime reloads.
- `plugin-catalog.ts`: Remote plugin catalog fetch with timeout, redirect rejection, response size cap, cache, and refresh support.
- `plugin-catalog-validation.ts`: Catalog V1 schema validation, duplicate id checks, semver/SHA fields, permissions canonicalization, and optional minimum OpenPets version.
- `plugin-package.ts`: Catalog plugin package download/install with HTTPS host/path allowlist, SHA-256 verification, ZIP size/entry restrictions, manifest/catalog consistency checks, and safe uninstall path resolution.
- `plugin-local-loader.ts`: Developer loader that validates a selected local folder and snapshots only the manifest into `plugins-dev` with symlink/path/size protections.
- `plugin-js-host.ts`: Sandboxed hidden BrowserWindow host for JavaScript plugin entry modules with per-plugin session partitioning, navigation/window-open hardening, SDK IPC tokening, registration handshake, config listener cleanup, and teardown.
- `plugin-sdk-bridge.ts`: Permission-checked JavaScript plugin SDK for pet speech/reactions, one-shot/repeating/daily schedules, storage with quotas, config listeners, commands, status, logs, and HTTPS-only public-host fetch.
**Agent Integration**:
- `agent-setup.ts`: Claude/OpenCode/Cursor detection, MCP configuration, hooks management, action journaling
@ -200,5 +213,6 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only ope
| Plugin catalog | `plugin-catalog.ts`/`plugin-service.ts` | Discoverable plugin metadata filtered by app version and install state |
| Plugin ZIP/local folder | `plugin-package.ts`/`plugin-local-loader.ts` | Validated manifest snapshot installed under `userData/plugins*` |
| `plugin-state.ts` | `userData/openpets-plugin-state.json` | Installed plugins, enabled flag, approved permissions, config, broken status |
| `plugin-runtime.ts` | `plugin-pet-api.ts` | Timer-triggered pet speech/reactions on the default pet |
| Plugins renderer | `windows.ts`/`plugin-service.ts` | Snapshot, enable, config, reload, install/update/uninstall, local-load operations |
| Control Center renderer | `control-center-preload.cjs`/`windows.ts` | Narrow Dashboard/Pets/Integrations/Plugins/Settings snapshots and route-targeted actions |
| `plugin-runtime.ts` | `plugin-pet-api.ts`/`plugin-js-host.ts`/`plugin-sdk-bridge.ts` | Declarative timers and JavaScript SDK actions on default pet, schedules, storage, commands, status, logs, and network |
| Plugins renderer | `windows.ts`/`plugin-service.ts` | Snapshot, enable, config, command, reload, install/update/uninstall, local-load operations |

View file

@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
import net from "node:net";
import { applyAgentPetReaction, applyAgentPetSay, clearAgentPetLeaseState, showAgentPet } from "./agent-pet-controller.js";
import { getAppStateSnapshot } from "./app-state.js";
import { getAppStateSnapshot, recordOpenPetsActivity } from "./app-state.js";
import { builtInPet } from "./built-in-pet.js";
import { applyExternalPetReaction, applyExternalPetSay, getDefaultPetPaused, isDefaultPetVisible } from "./default-pet-controller.js";
import { createStaleLeaseStatus, LeaseManager } from "./lease-manager.js";
@ -306,13 +306,16 @@ async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
const params = isRecord(request.params) ? request.params : {};
const reaction = validateReaction(params.reaction);
const lease = getLeaseTarget(params.leaseId);
const petId = lease?.actualTargetPetId ?? getCurrentDefaultPet().id;
debug("ipc", "pet react requested", { requestId: request.id, reaction, leaseId: lease?.leaseId, targetKind: lease?.targetKind, actualPetId: lease?.actualTargetPetId });
if (lease?.targetKind === "explicit") {
if (getDefaultPetPaused()) return { ok: true, reaction, shown: false, reason: "paused", leaseId: lease.leaseId };
const applied = applyAgentPetReaction(lease.actualTargetPetId, reaction);
safeRecordOpenPetsActivity({ kind: "react", reaction, petId });
return { ok: true, reaction, shown: applied.shown, reason: applied.reason, leaseId: lease.leaseId };
}
const applied = applyExternalPetReaction(reaction);
safeRecordOpenPetsActivity({ kind: "react", reaction, petId });
return { ok: true, reaction, shown: applied.shown, reason: applied.reason };
}
@ -320,16 +323,27 @@ async function handleRequest(request: OpenPetsIpcRequest): Promise<unknown> {
const message = validateSayMessage(params.message);
const reaction = params.reaction === undefined ? undefined : validateReaction(params.reaction);
const lease = getLeaseTarget(params.leaseId);
const petId = lease?.actualTargetPetId ?? getCurrentDefaultPet().id;
debug("ipc", "pet say requested", { requestId: request.id, reaction, messageLength: message.length, leaseId: lease?.leaseId, targetKind: lease?.targetKind, actualPetId: lease?.actualTargetPetId });
if (lease?.targetKind === "explicit") {
if (getDefaultPetPaused()) return { ok: true, shown: false, reason: "paused", reaction, leaseId: lease.leaseId };
const applied = applyAgentPetSay(lease.actualTargetPetId, message, reaction);
safeRecordOpenPetsActivity({ kind: "say", reaction, petId });
return { ok: true, shown: applied.shown, reason: applied.reason, reaction, leaseId: lease.leaseId };
}
const applied = applyExternalPetSay(message, reaction);
safeRecordOpenPetsActivity({ kind: "say", reaction, petId });
return { ok: true, shown: applied.shown, reason: applied.reason, reaction };
}
function safeRecordOpenPetsActivity(activity: Parameters<typeof recordOpenPetsActivity>[0]): void {
try {
recordOpenPetsActivity(activity);
} catch (error) {
debug("ipc", "activity record failed", { error: error instanceof Error ? error.message : String(error), kind: activity.kind, reaction: activity.reaction, petId: activity.petId });
}
}
function validateRequiredLeaseId(value: unknown): string {
const leaseId = validateOptionalLeaseId(value);
if (!leaseId) throw new IpcProtocolError("invalid_params", "Lease id is required.");

View file

@ -1,7 +1,7 @@
import { app } from "electron";
import { delimiter, resolve } from "node:path";
import { initializeAppState, isOnboardingCompleted, releaseStartupInstallLock } from "./app-state.js";
import { initializeAppState, releaseStartupInstallLock } from "./app-state.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";
@ -11,7 +11,7 @@ import { ElectronPluginJsHost } from "./plugin-js-host.js";
import { initializePluginService } from "./plugin-service.js";
import { createAppTray, refreshTrayMenu } from "./tray.js";
import { checkForGitHubReleaseUpdate } from "./update-checker.js";
import { installInternalUiHandlers, installInternalUiProtocol, openTaskWindow } from "./windows.js";
import { installInternalUiHandlers, installInternalUiProtocol } from "./windows.js";
// OpenPets does not store browser passwords, cookies, or encrypted app secrets.
// Keep Chromium/Electron from prompting for macOS Keychain or Linux keyring access
@ -51,22 +51,16 @@ if (!gotSingleInstanceLock) {
installDefaultPetDisplayHandlers();
await startLocalIpcServer();
releaseStartupInstallLock();
const roots = parseDevPluginEnv(process.env.OPENPETS_DEV_PLUGIN_ROOTS);
const paths = parseDevPluginEnv(process.env.OPENPETS_DEV_PLUGIN_PATHS);
const devPluginMode = roots.length > 0 || paths.length > 0;
const pluginService = initializePluginService(app.getPath("userData"), defaultPluginPetApi, app.getVersion(), new ElectronPluginJsHost(), writePluginRuntimeLog, process.env.OPENPETS_DISABLE_PLUGIN_CATALOG === "1" || devPluginMode);
if (shouldOpenDefaultPetOnLaunch()) {
showDefaultPet();
}
if (!isOnboardingCompleted()) {
try {
openTaskWindow("onboarding");
} catch (error) {
console.error("Failed to open OpenPets onboarding; continuing with tray app.", error);
}
}
refreshTrayMenu();
void (async () => {
const roots = parseDevPluginEnv(process.env.OPENPETS_DEV_PLUGIN_ROOTS);
const paths = parseDevPluginEnv(process.env.OPENPETS_DEV_PLUGIN_PATHS);
const devPluginMode = roots.length > 0 || paths.length > 0;
const service = initializePluginService(app.getPath("userData"), defaultPluginPetApi, app.getVersion(), new ElectronPluginJsHost(), writePluginRuntimeLog, process.env.OPENPETS_DISABLE_PLUGIN_CATALOG === "1" || devPluginMode);
const service = pluginService;
await service.start();
for (const path of paths) {
const result = await service.loadLocalPath(path, { autoApprove: true });
@ -78,7 +72,7 @@ if (!gotSingleInstanceLock) {
}
})().catch((error) => logError("app", "plugin service startup failed", error));
void checkForGitHubReleaseUpdate().then(() => refreshTrayMenu());
info("app", "startup complete", { logFile: getLogFilePath(), openDefaultPetOnLaunch: shouldOpenDefaultPetOnLaunch(), onboardingCompleted: isOnboardingCompleted() });
info("app", "startup complete", { logFile: getLogFilePath(), openDefaultPetOnLaunch: shouldOpenDefaultPetOnLaunch() });
console.log("OpenPets desktop shell ready.");
}).catch((error: unknown) => {
releaseStartupInstallLock();

View file

@ -114,6 +114,7 @@ export class PluginService {
const record = this.stateStore.getRecord(id);
if (!record) return this.#error("Plugin is not installed.");
if (enabled && record.catalogDisabled) return this.#error("Plugin is disabled in the catalog.");
if (enabled && record.brokenReason) return this.#error(record.brokenReason);
this.stateStore.setEnabled(id, enabled);
await this.runtime.reloadPlugin(id);
return { ok: true, snapshot: await this.getSnapshot() };

View file

@ -1,189 +0,0 @@
export type PluginsWindowDefinition = {
readonly title: string;
readonly heading: string;
readonly description: string;
};
export function createPluginsHtml(definition: PluginsWindowDefinition): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-src 'none'" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(definition.title)}</title>
<style>
:root { color-scheme: light; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 12% 8%, rgba(219, 234, 254, 0.9), transparent 24%), linear-gradient(180deg, #f8fbff 0%, #eff7ff 54%, #e9f3ff 100%); color: #102149; overflow-y: scroll; }
main { width: min(1040px, calc(100vw - 48px)); margin: 0 auto; padding: 48px 0 64px; }
/* Typography */
h1, h2, h3 { margin: 0; font-weight: 600; tracking: -0.02em; text-wrap: balance; }
h1 { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 32px; line-height: 1; letter-spacing: -0.05em; color: #102149; margin-bottom: 8px; }
h2 { font-size: 20px; margin-bottom: 12px; color: #102149; }
h3 { font-size: 16px; margin-bottom: 16px; color: #102149; }
p { color: #63708f; line-height: 1.6; margin: 0; text-wrap: pretty; }
.muted { color: #63708f; font-size: 14px; }
/* Header */
header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 32px; }
/* Buttons */
button, input, select, textarea { font: inherit; box-sizing: border-box; }
button {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
min-height: 38px; padding: 0 12px; border-radius: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; font-weight: 950;
cursor: pointer; user-select: none;
border: 1px solid rgba(37, 99, 235, 0.34); background: rgba(255,255,255,0.76); color: #176df2;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.9), 0 8px 18px rgba(61, 99, 160, 0.08);
transition: transform 0.1s cubic-bezier(0.4, 0, 0.2, 1);
}
button:hover:not(:disabled) { transform: translateY(-1px); }
button:active:not(:disabled) { transform: scale(0.96); }
button:disabled { cursor: default; opacity: 0.54; transform: none; }
button.primary { background: linear-gradient(180deg, #3b96ff, #176df2); color: #fff; box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24), inset 0 1px 0 rgba(255,255,255,0.38); border-color: transparent; }
button.primary:hover:not(:disabled) { background: linear-gradient(180deg, #55a6ff, #176df2); }
button.secondary { background: rgba(255,255,255,0.76); color: #176df2; border-color: rgba(37, 99, 235, 0.42); box-shadow: inset 0 1px 0 rgba(255,255,255,0.9), 0 8px 18px rgba(61, 99, 160, 0.08); }
button.secondary:hover:not(:disabled) { background: rgba(255,255,255,0.95); border-color: rgba(37, 99, 235, 0.55); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 8px 18px rgba(61, 99, 160, 0.12); }
button.compact { min-height: 32px; padding: 0 10px; font-size: 11px; border-radius: 9px; }
button.danger { color: #dc2626; border-color: rgba(220, 38, 38, 0.3); }
/* Layout & Views */
.view-section { display: none; }
.view-section.active { display: block; }
/* Plugin Hub Grid */
.plugin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-top: 24px; }
/* Cards */
.plugin-card { min-height: 248px; box-sizing: border-box; display: flex; flex-direction: column; gap: 9px; border: 1px solid rgba(126, 161, 210, 0.44); border-radius: 20px; background: rgba(255,255,255,0.76); box-shadow: 0 16px 38px rgba(61, 99, 160, 0.1), inset 0 1px 0 rgba(255,255,255,0.94); padding: 16px; }
.plugin-card.featured { border-color: rgba(37, 99, 235, 0.36); background: linear-gradient(180deg, rgba(239, 247, 255, 0.92), rgba(255,255,255,0.78)); }
.plugin-card.disabled { opacity: 0.74; filter: grayscale(0.5); }
.plugin-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.plugin-icon { width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid rgba(126, 161, 210, 0.34); border-radius: 16px; background: rgba(255,255,255,0.76); box-shadow: inset 0 1px 0 rgba(255,255,255,0.9), 0 10px 20px rgba(61, 99, 160, 0.08); font-size: 24px; }
.plugin-icon img, .plugin-icon svg { width: 28px; height: 28px; object-fit: contain; }
.plugin-card h2 { margin: 0; font-size: 20px; color: #102149; }
.plugin-card p { flex: 1 1 auto; margin: 0; color: #526483; line-height: 1.35; font-size: 14px; }
/* Status Pills */
.status-pill { height: 30px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border: 1px solid rgba(126, 161, 210, 0.36); border-radius: 10px; padding: 0 10px; background: rgba(255,255,255,0.68); color: #526483; font-size: 10px; font-weight: 900; white-space: nowrap; text-transform: uppercase; letter-spacing: 0.05em; }
.status-pill.success { color: #047857; background: rgba(236, 253, 245, 0.86); border-color: rgba(16, 185, 129, 0.28); }
.status-pill.info { color: #176df2; background: rgba(239, 246, 255, 0.9); border-color: rgba(37, 99, 235, 0.28); }
.status-pill.error { color: #b91c1c; background: rgba(254, 242, 242, 0.9); border-color: rgba(248, 113, 113, 0.32); }
.status-pill.muted { color: #64748b; background: rgba(248, 250, 252, 0.82); }
/* Actions */
.plugin-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: auto; }
.plugin-actions.stacked { grid-template-columns: 1fr; gap: 8px; }
.plugin-actions button:only-child { grid-column: 1 / -1; }
/* Detail View */
.detail-toolbar { display: flex; justify-content: flex-start; margin-bottom: 24px; padding: 4px 2px 0; }
.detail-pane { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
.detail-header { display: flex; align-items: flex-start; gap: 20px; margin-bottom: 16px; }
.detail-header-text { flex: 1 1 auto; }
.detail-header-text .eyebrow { margin: 0 0 4px; color: #2478ff; font-weight: 900; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
/* Config Sections */
.config-card { box-sizing: border-box; border: 1px solid rgba(126, 161, 210, 0.44); border-radius: 20px; background: rgba(255,255,255,0.76); box-shadow: 0 16px 38px rgba(61, 99, 160, 0.1), inset 0 1px 0 rgba(255,255,255,0.94); padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.config-section-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 4px; }
.config-section-header span:first-child { display: grid; gap: 4px; }
.config-section-header small { color: #2478ff; font-size: 11px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; }
.config-section-header strong { color: #102149; font-size: 18px; line-height: 1.2; }
/* Form Controls */
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { color: #102149; font-weight: 700; font-size: 14px; }
.form-group .help-text { color: #63708f; font-size: 12px; margin-bottom: 4px; }
.form-input { width: 100%; box-sizing: border-box; min-height: 38px; border: 1px solid rgba(126, 161, 210, 0.54); border-radius: 11px; background: rgba(255,255,255,0.82); color: #17284f; padding: 0 12px; outline: none; }
.form-input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); }
textarea.form-input { padding: 10px 12px; min-height: 80px; resize: vertical; font-family: inherit; }
/* Toggle Row */
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 0; }
.toggle-row-text { display: flex; flex-direction: column; gap: 2px; }
.toggle-row-text strong { color: #102149; font-size: 14px; }
.toggle-row-text small { color: #63708f; font-size: 12px; }
/* CSS Toggle Switch */
.toggle-switch { position: relative; width: 44px; height: 24px; display: inline-block; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #cbd5e1; transition: .2s; border-radius: 24px; }
.toggle-slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 2px; bottom: 2px; background-color: white; transition: .2s; border-radius: 50%; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
input:checked + .toggle-slider { background-color: #10b981; }
input:focus + .toggle-slider { box-shadow: 0 0 1px #10b981; }
input:checked + .toggle-slider:before { transform: translateX(20px); }
/* Chips / MultiSelect */
.chips-container { display: flex; flex-wrap: wrap; gap: 8px; }
.chip-label { display: inline-flex; align-items: center; cursor: pointer; }
.chip-label input { position: absolute; opacity: 0; width: 0; height: 0; }
.chip-text { display: inline-flex; align-items: center; justify-content: center; min-height: 32px; padding: 0 12px; border-radius: 16px; border: 1px solid rgba(126, 161, 210, 0.44); background: rgba(255,255,255,0.76); color: #526483; font-size: 13px; font-weight: 600; transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; user-select: none; }
.chip-label input:checked + .chip-text { background: rgba(239, 246, 255, 0.9); border-color: rgba(37, 99, 235, 0.5); color: #176df2; box-shadow: inset 0 1px 0 rgba(255,255,255,0.9); }
/* List Editor */
.list-editor { display: flex; flex-direction: column; gap: 12px; }
.list-item-card { border: 1px solid rgba(126, 161, 210, 0.3); border-radius: 16px; background: rgba(255,255,255,0.5); padding: 16px; position: relative; display: flex; flex-direction: column; gap: 12px; }
.list-item-remove { position: absolute; top: 12px; right: 12px; background: none; border: none; box-shadow: none; color: #94a3b8; padding: 4px; min-height: auto; border-radius: 6px; }
.list-item-remove:hover { background: rgba(254, 242, 242, 0.8); color: #ef4444; }
/* Dev Mode Toggle */
.dev-toggle-container { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #64748b; margin-top: 24px; justify-content: flex-end; }
/* Spinner */
@keyframes spin { to { transform: rotate(360deg); } }
.loading-icon { animation: spin 0.85s linear infinite; }
/* Messages */
.empty-state { text-align: center; padding: 48px; color: #64748b; }
</style>
</head>
<body data-openpets-view="plugins">
<main>
<header>
<div>
<h1>${escapeHtml(definition.heading)}</h1>
<p>${escapeHtml(definition.description)}</p>
</div>
<button id="plugins-refresh" type="button" class="secondary">Refresh</button>
</header>
<section id="plugins-status" class="muted" aria-live="polite">Loading plugins</section>
<div id="hub-view" class="view-section active">
<div class="plugin-grid" id="plugins-grid">
<!-- Cards rendered by preload -->
</div>
<div class="dev-toggle-container">
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" id="dev-mode-toggle" />
Developer mode
</label>
</div>
<div id="dev-actions" style="display: none; margin-top: 16px; text-align: right;">
<button id="plugins-load-local" class="secondary">Load local plugin folder</button>
</div>
</div>
<div id="detail-view" class="view-section">
<div class="detail-toolbar">
<button id="back-to-hub" class="secondary compact">Back to plugins</button>
</div>
<div class="detail-pane" id="plugin-detail-content">
<!-- Detail content rendered by preload -->
</div>
</div>
</main>
</body>
</html>`;
}
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}

View file

@ -0,0 +1,18 @@
# apps/desktop/src/renderer/
## Responsibility
Vite renderer workspace for the desktop React/Tailwind Control Center. It owns the packaged `index.html` entry and delegates UI implementation to `src/`, while Electron main-process code owns windows, IPC handlers, and service logic.
## Design
- Built as a sandboxed Electron renderer loaded by the singleton Control Center BrowserWindow.
- Uses `control-center-preload.cjs` as the only bridge to the main process; `ipcRenderer` is not exposed directly.
- Production output is emitted to `apps/desktop/dist/renderer/`; development can load a loopback-only Vite URL.
- Transparent pet windows and plugin JavaScript host windows are separate lightweight renderers and do not live here.
## Key Files
- `index.html`: Vite HTML entry mounting the React app.
- `src/main.tsx`: Route shell and pages for Dashboard, Pets, Integrations, Plugins, and Settings.
- `src/styles.css`: Tailwind directives plus Control Center component styling.

View file

@ -0,0 +1,13 @@
<!doctype html>
<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 name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenPets Control Center</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,21 @@
# apps/desktop/src/renderer/src/
## Responsibility
React/Tailwind source for the Control Center management UI. This renderer presents dashboard status, pet management, coding-agent integrations, plugin management, and settings using narrow preload APIs backed by `windows.ts` IPC handlers and desktop services.
## Design
- **Route Shell**: In-renderer route state supports `dashboard`, `pets`, `integrations`, `plugins`, and `settings`; tray actions retarget the singleton window through route-change events.
- **Dashboard**: Reads a narrowed dashboard snapshot for default pet preview, install/catalog counts, plugin health, update status, and activity totals.
- **Pets**: Combines installed pets, catalog v3 pages/search, Codex imports, filters, detail panes, set-default/install/import/remove actions, and animated sprite previews.
- **Integrations**: Card-first setup UI for Claude Code, OpenCode, Cursor, and Pi guidance, including command mode/path controls and preview/action flows.
- **Plugins**: Gallery-first plugin hub for installed/catalog/local/broken filters, catalog refresh, local load, install/update/uninstall, enable/disable, config modal, command execution, runtime/status display, and broken-state feedback.
- **Settings**: Startup, launch-at-login, pet scale, reaction-animation mapping, update check, default-pet position reset, and pet reaction previews.
- **Bridge Contract**: All data and actions go through `window.openPetsControlCenter`; page snapshots intentionally omit raw install paths and unrelated app state.
## Key Files
- `main.tsx`: Single-file React app containing type definitions, route shell, page components, icons, snapshot loading, and action handlers.
- `styles.css`: Tailwind base/components/utilities plus glass-card layout, navigation, galleries, modals, status pills, previews, and notifications.
- `vite-env.d.ts`: Vite/TypeScript renderer environment declarations.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,268 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body { margin:0; min-height:100vh; overflow:hidden; color:#102149; background: radial-gradient(circle at 12% 8%, rgba(219, 234, 254, 0.9), transparent 24%), linear-gradient(180deg, #f8fbff 0%, #eff7ff 54%, #e9f3ff 100%); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(96, 165, 250, 0.25); border-radius: 100px; }
::-webkit-scrollbar-thumb:hover { background: rgba(96, 165, 250, 0.45); }
}
@layer components {
.app-shell { @apply mx-auto flex h-screen max-w-[1160px] flex-col p-6; }
.hero { @apply mb-6 flex items-center justify-between gap-6; }
.hero-content { @apply flex flex-col justify-center; }
.hero h1 { @apply m-0 font-monoDisplay text-5xl font-black tracking-tight text-navy leading-none; }
.hero-desc { @apply m-0 mt-1.5 min-h-10 max-w-md text-sm font-medium leading-5 text-slatecopy; }
.hero-logo-container { @apply flex shrink-0 items-center justify-end; }
.hero-brand-logo { @apply h-28 w-auto max-w-[480px] object-contain select-none; filter: drop-shadow(0 6px 12px rgba(61, 99, 160, 0.10)); }
.eyebrow { @apply mb-1.5 font-monoDisplay text-xs font-black uppercase tracking-[.18em] text-brand; }
.layout { @apply grid min-h-0 flex-1 grid-cols-1 gap-5; }
.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 { @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; }
.filter.active { @apply text-white; border-color: rgba(29, 78, 216, 0.5); background: linear-gradient(180deg, #3b96ff 0%, #176df2 100%); box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.35), 0 2px 4px rgba(61,99,160,0.10); }
.filter.active:hover { background: linear-gradient(180deg, #55a6ff 0%, #176df2 100%); }
.filter.active:active { translate: 0 0; }
.filter.original.active { border-color: rgba(194, 65, 12, 0.5); background: linear-gradient(180deg, #fb923c 0%, #ea580c 100%); box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.35), 0 2px 4px rgba(61,99,160,0.10); }
.filter.original.active:hover { background: linear-gradient(180deg, #ffaa47 0%, #ea580c 100%); }
.filter.featured.active { border-color: rgba(126, 34, 206, 0.5); background: linear-gradient(180deg, #a855f7 0%, #7e22ce 100%); box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.35), 0 2px 4px rgba(61,99,160,0.10); }
.filter.featured.active:hover { background: linear-gradient(180deg, #c084fc 0%, #7e22ce 100%); }
.filter-icon-wrapper { @apply flex items-center justify-center shrink-0 opacity-70; }
.filter.active .filter-icon-wrapper { @apply opacity-100; }
.filter-icon { @apply h-3.5 w-3.5 stroke-[2.5]; }
.pager { @apply mt-3 flex items-center justify-between gap-2.5 rounded-xl border border-blue-200/40 bg-blue-50/20 p-1.5 text-xs font-bold text-slatecopy; }
.pets-grid { @apply grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-y-auto p-1.5 pr-2.5 pb-3; overscroll-behavior: contain; scrollbar-color: rgba(96, 165, 250, 0.42) transparent; }
.pet-card { @apply relative flex flex-row items-start gap-3.5 rounded-2xl border border-slate-200 bg-white/80 p-3 text-left shadow-sm transition-[transform,border-color,background-color,box-shadow] hover:-translate-y-0.5 hover:border-brand hover:bg-white min-h-[138px] w-full; }
.pet-card.selected { @apply border-brand bg-blue-50/90 ring-4 ring-blue-200/50 shadow-md; }
.pet-card-actions { @apply relative z-10 mt-2 grid grid-cols-3 gap-1.5; }
.pet-card-actions .btn { @apply min-h-[30px] min-w-0 px-2 py-1 text-[10px] tracking-wide; }
.thumb { @apply relative z-10 grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-2xl bg-gradient-to-br from-white to-blue-100 border border-blue-100/50; }
.thumb img { @apply max-h-12 max-w-12 object-contain; }
.card-content { @apply relative z-10 flex flex-col flex-1 min-w-0 justify-center gap-1; }
.card-title-row { @apply flex items-center justify-between gap-2; }
.card-title { @apply font-semibold text-navy text-sm truncate block; }
.card-desc { @apply text-xs text-slatecopy line-clamp-1 m-0; }
.badges { @apply flex flex-wrap gap-1 mt-1; }
.pill { @apply inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 font-sans text-[11px] font-semibold text-slatecopy/90 border border-transparent select-none; } .pill-blue { @apply bg-blue-50 border border-blue-100/50 text-blue-700; } .pill-green { @apply bg-emerald-50 border border-emerald-100/50 text-emerald-700; } .pill-orange { @apply bg-amber-50 border border-amber-100/50 text-amber-700; } .pill-purple { @apply bg-purple-50 border border-purple-100/60 text-purple-700; } .pill-yellow { @apply bg-yellow-100 border border-yellow-200/70 text-yellow-900; } .pill-red { @apply bg-red-50 border border-red-100/50 text-red-700; } .pill-slate { @apply bg-slate-50 border border-slate-200 text-slate-600; }
.detail { @apply flex min-h-0 flex-col overflow-y-auto pr-2; overscroll-behavior: contain; }
.detail h2 { @apply m-0 font-monoDisplay text-3xl font-black; } .desc { @apply text-slatecopy; } .stage { @apply my-5 grid min-h-72 place-items-center overflow-hidden rounded-[32px] border border-blue-100 bg-[radial-gradient(circle_at_center,rgba(59,150,255,.18),rgba(255,255,255,.74)_55%,rgba(239,247,255,.9))]; } .stage img { @apply max-h-56 max-w-64 object-contain [image-rendering:auto]; }
.sprite-frame { display: block; flex: 0 0 auto; overflow: hidden; image-rendering: auto; }
.sprite-frame image { pointer-events: none; user-select: none; }
.sprite-thumb { width: 54px; height: 58px; filter: drop-shadow(0 9px 8px rgba(25, 44, 83, 0.16)); }
.sprite-detail { width: 144px; height: 156px; filter: drop-shadow(0 18px 18px rgba(25, 44, 83, 0.22)); }
.sprite-mini { width: 56px; height: 61px; border-radius: 14px; filter: drop-shadow(0 4px 6px rgba(25, 44, 83, 0.12)); }
.sprite-detail.sprite-idle { animation: cc-idle-bob 2.6s ease-in-out infinite; }
@keyframes cc-idle-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
@media (prefers-reduced-motion: reduce) { .sprite-frame { animation: none !important; } .sprite-frame animate { display: none; } }
@media (max-width: 900px) { body { overflow:auto; } .app-shell { @apply h-auto min-h-screen; } .layout, .gallery, .pets-grid, .detail { @apply overflow-visible; } .pets-grid { @apply grid-cols-1; } .pet-detail-content { @apply grid-cols-1; } .pet-preview-grid { @apply grid-cols-1; } }
.meta { @apply flex flex-wrap gap-2; }
.btn { @apply rounded-2xl px-4 py-3 font-monoDisplay text-sm font-black uppercase tracking-wide disabled:cursor-not-allowed disabled:opacity-60 active:scale-[0.96] transition-[transform,translate,background-color,border-color,box-shadow] duration-150 flex items-center justify-center min-h-[44px] cursor-pointer select-none; }
.btn-compact { @apply rounded-xl px-2.5 py-1 text-xs min-h-[30px]; }
.btn-primary { border: 1px solid rgba(37, 99, 235, 0.32); background: linear-gradient(180deg, #3b96ff, #176df2); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10); }
.btn-primary:hover:not(:disabled) { background: linear-gradient(180deg, #55a6ff, #176df2); }
.btn-secondary { background: rgba(255,255,255,0.76); color: #176df2; border: 1px solid rgba(37, 99, 235, 0.42); box-shadow: inset 0 1px 0 rgba(255,255,255,0.9), 0 2px 4px rgba(61,99,160,0.08); }
.btn-secondary:hover:not(:disabled) { background: rgba(255,255,255,0.95); border-color: rgba(37, 99, 235, 0.55); box-shadow: inset 0 1px 0 rgba(255,255,255,1), 0 2px 4px rgba(61,99,160,0.10); }
.btn-danger { border: 1px solid rgba(220, 38, 38, 0.32); background: linear-gradient(180deg, #ff6b6b, #dc2626); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10); }
.btn-danger:hover:not(:disabled) { background: linear-gradient(180deg, #ff8787, #dc2626); }
.btn-success { border: 1px solid rgba(5, 150, 105, 0.32); background: linear-gradient(180deg, #34d399, #059669); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,0.38), 0 2px 4px rgba(61,99,160,0.10); }
.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); }
.nav-tab.active { @apply text-white; border-color: rgba(29, 78, 216, 0.5); background: linear-gradient(180deg, #3b96ff 0%, #176df2 100%); box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.35), 0 2px 4px rgba(61,99,160,0.10); }
.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-[transform,background-color,border-color,box-shadow,color] 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-40 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; }
.plugins-layout { @apply relative flex min-h-0 flex-1; }
.plugins-hub { @apply flex min-h-0 flex-col overflow-hidden; }
.plugins-layout .plugins-hub { @apply w-full; }
.plugin-grid { @apply grid min-h-0 flex-1 grid-cols-2 gap-4 overflow-y-auto p-1.5 pr-2.5 pb-3 max-[850px]:grid-cols-1; overscroll-behavior: contain; }
.plugin-card { @apply flex w-full flex-col rounded-[24px] border border-slate-200 bg-white/80 p-0 text-left shadow-sm transition-[border-color,background-color,box-shadow]; }
.plugin-card:hover { @apply border-brand/40 bg-white; }
.plugin-card.broken { @apply border-red-200 bg-red-50/60; }
.plugin-card-body { @apply flex items-start gap-4 p-4; }
.plugin-card-icon { @apply grid h-14 w-14 shrink-0 place-items-center rounded-2xl border border-blue-100/60 bg-gradient-to-br from-white to-blue-100 text-brand shadow-sm; }
.plugin-glyph { @apply h-7 w-7 stroke-[2.5]; }
.plugin-card-content { @apply flex min-w-0 flex-1 flex-col gap-1; }
.plugin-card-content strong { @apply truncate text-base font-black text-navy tracking-tight; }
.plugin-card-content small { @apply line-clamp-2 text-xs leading-relaxed text-slatecopy; }
.plugin-card-footer { @apply flex items-center justify-between border-t border-blue-50/50 bg-blue-50/10 px-4 py-3 rounded-b-[24px]; }
.plugin-card-meta { @apply flex items-center gap-2; }
.plugin-card-actions { @apply flex items-center gap-3; }
.plugin-card-toggle-zone { @apply flex items-center gap-2 border-r border-blue-100/50 pr-3; }
.plugin-card-toggle-label { @apply font-monoDisplay text-[10px] font-black uppercase tracking-wider text-slatecopy/60; }
.plugin-card-toggle { @apply h-5 w-9; }
.plugin-hub-footer { @apply mt-3 flex items-center justify-between gap-3 rounded-xl border border-blue-200/40 bg-blue-50/20 p-2 text-xs font-bold text-slatecopy; }
.plugin-hub-actions { @apply flex flex-wrap items-center justify-end gap-2; }
.plugin-empty { @apply flex min-h-48 flex-col items-center justify-center gap-2 rounded-3xl border border-dashed border-blue-200 bg-blue-50/30 p-8 text-center text-slatecopy; }
.plugin-empty strong { @apply text-sm font-black text-navy; }
.plugin-empty small { @apply max-w-xs text-xs leading-relaxed; }
.plugin-empty-detail { @apply min-h-full; }
.plugin-config-overlay { @apply fixed inset-0 z-30 flex items-center justify-center p-6; animation: plugin-fade-in 0.2s ease-out; }
.plugin-config-backdrop { @apply absolute inset-0 cursor-default border-0 bg-navy/[0.12] p-0 backdrop-blur-[4px]; }
.plugin-inspector { @apply relative z-10 flex max-h-[calc(100vh-4rem)] w-[min(760px,calc(100vw-3rem))] flex-col gap-4 overflow-y-auto pr-3 shadow-[0_32px_80px_rgba(16,33,73,0.25)]; overscroll-behavior: contain; scrollbar-gutter: stable; animation: plugin-pop-in 0.25s cubic-bezier(0.16, 1, 0.3, 1); }
.pet-detail-inspector { @apply w-[min(900px,calc(100vw-3rem))]; }
.pet-detail-content { @apply grid gap-5 grid-cols-[1.1fr_0.9fr] items-start; }
.pet-detail-main { @apply flex flex-col gap-3; }
.pet-detail-reactions { @apply rounded-2xl border border-blue-100/70 bg-white/55 p-3; }
.pet-preview-grid { @apply grid gap-3; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.pet-preview-item { @apply flex flex-col items-center justify-center gap-2 rounded-2xl border border-blue-100 bg-white/60 p-2.5 shadow-sm; }
.pet-detail-actions .grid { @apply grid-cols-1 sm:grid-cols-2; }
@keyframes plugin-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes plugin-pop-in { from { opacity: 0; transform: scale(0.96) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.plugin-inspector-head { @apply flex items-center gap-4; }
.plugin-inspector-icon { @apply grid h-16 w-16 shrink-0 place-items-center rounded-[22px] border border-blue-100/70 bg-gradient-to-br from-white to-blue-100 text-brand shadow-sm; }
.plugin-inspector h2 { @apply m-0 font-monoDisplay text-3xl font-black text-navy leading-tight; }
.plugin-status-strip { @apply flex items-center gap-2 rounded-2xl border border-blue-100/60 bg-blue-50/60 p-3 text-xs font-semibold text-slatecopy shadow-inner; }
.plugin-section { @apply flex flex-col gap-3 rounded-[24px] border border-blue-100/70 bg-white/65 p-5 shadow-sm; }
.plugin-section-danger { @apply border-red-200 bg-red-50/70 text-red-700; }
.plugin-section-danger ul { @apply m-0 pl-5 text-sm; }
.plugin-section-title { @apply flex flex-col gap-0.5; }
.plugin-section-title small { @apply font-monoDisplay text-xs font-black uppercase tracking-[.16em] text-brand; }
.plugin-section-title strong { @apply text-base font-black text-navy; }
.plugin-toggle-row { @apply rounded-2xl border-b-0 bg-white/65; }
.plugin-permissions { @apply mt-0; }
.plugin-config-form { @apply flex flex-col gap-3; }
.plugin-config-row { @apply flex flex-col gap-2 rounded-2xl border border-blue-50 bg-white/60 p-3; }
.plugin-config-row > span:first-child { @apply flex flex-col gap-0.5; }
.plugin-config-row strong { @apply text-sm font-bold text-navy; }
.plugin-config-row small { @apply text-xs leading-relaxed text-slatecopy; }
.plugin-config-row-boolean { @apply flex-row items-center justify-between; }
.plugin-input { @apply min-h-10 w-full rounded-xl border border-blue-200/80 bg-white/80 px-3 py-2 text-sm font-semibold text-navy outline-none shadow-inner focus:border-brand focus:ring-4 focus:ring-brand/15; }
.plugin-textarea { @apply min-h-24 resize-y font-sans; }
.plugin-select { @apply w-full; }
.plugin-chip-list { @apply flex flex-wrap gap-2; }
.plugin-chip { @apply min-h-10 rounded-xl border border-blue-200/60 bg-white/80 px-3 py-1.5 text-xs font-bold text-slatecopy shadow-sm transition-[transform,background-color,border-color,box-shadow] active:scale-[0.96]; }
.plugin-chip.active { @apply border-brand bg-blue-50 text-brand ring-2 ring-blue-100; }
.plugin-list-editor { @apply flex flex-col gap-3; }
.plugin-list-item { @apply flex flex-col gap-3 rounded-2xl border border-blue-100/70 bg-blue-50/25 p-3; }
.plugin-list-item-header { @apply flex items-center justify-between text-xs font-bold uppercase tracking-wider text-slatecopy; }
.plugin-command-list { @apply flex flex-wrap gap-2; }
.plugin-actions-section { @apply grid grid-cols-3 gap-2; }
.plugin-actions-section .btn-secondary { @apply bg-blue-50/70; }
.plugin-actions-section .btn-primary { @apply min-w-[96px]; }
.plugin-actions-section .btn-danger { @apply min-w-[112px]; }
@media (max-width: 900px) { .plugins-layout, .plugins-hub, .plugin-grid { @apply overflow-visible; } .plugin-actions-section { @apply grid-cols-1; } }
.integration-grid { @apply grid grid-cols-2 gap-4 max-[850px]:grid-cols-1; }
.integration-card { @apply flex min-h-[190px] flex-col justify-between rounded-3xl border border-blue-100/70 bg-white/70 p-4 text-left shadow-sm transition-[transform,border-color,background-color,box-shadow] active:scale-[0.96]; }
.integration-card:hover { @apply border-brand/45 bg-white/90 shadow-md; }
.integration-icon { @apply grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-2xl border border-blue-100/70 bg-blue-50/70 text-brand shadow-sm; }
.integration-logo { @apply h-9 w-9 object-contain; }
.integration-icon svg { @apply w-8 h-8; }
/* Dashboard */
.dashboard-layout { @apply flex flex-col gap-6 overflow-y-auto pr-2 pb-8 h-full; overscroll-behavior: contain; }
.dashboard-hero { @apply relative flex items-center justify-between overflow-hidden rounded-[32px] border border-blue-200/50 bg-gradient-to-br from-blue-500 to-blue-600 p-8 text-white shadow-lg min-h-[180px]; }
.dashboard-hero::before { content: ""; @apply absolute inset-0 opacity-20; background-image: radial-gradient(circle at 2px 2px, white 1px, transparent 0); background-size: 24px 24px; }
.dashboard-hero-content { @apply relative z-10 flex flex-col gap-1.5 flex-1 min-w-0; }
.dashboard-hero-title { @apply m-0 font-monoDisplay text-4xl font-black leading-tight tracking-tight; text-wrap: balance; }
.dashboard-hero-desc { @apply max-w-md text-sm font-medium text-blue-50 opacity-90; text-wrap: pretty; }
.dashboard-hero-pet { @apply relative z-10 flex shrink-0 items-center justify-center ml-6; }
.dashboard-hero-pet .sprite-detail { filter: drop-shadow(0 20px 25px rgba(0, 0, 0, 0.25)); }
.dashboard-grid { @apply grid grid-cols-3 gap-5; }
.dashboard-stat-card { @apply flex flex-col gap-4 rounded-[28px] border border-blue-100/50 bg-white/80 p-6 shadow-sm transition-[border-color,background-color,box-shadow] duration-150 hover:border-blue-200 hover:bg-white hover:shadow-md; }
.dashboard-stat-header { @apply flex items-center gap-3; }
.dashboard-stat-icon { @apply flex h-10 w-10 items-center justify-center rounded-2xl bg-blue-50 text-brand; }
.dashboard-stat-label { @apply font-monoDisplay text-[11px] font-black uppercase tracking-wider text-slatecopy/70; }
.dashboard-stat-value { @apply font-monoDisplay text-3xl font-black tracking-tight text-navy tabular-nums; }
.dashboard-stat-footer { @apply text-[11px] font-bold text-slatecopy opacity-60; }
.dashboard-row { @apply grid grid-cols-[1fr_320px] gap-5; }
.dashboard-activity-card { @apply flex flex-col gap-4; }
.dashboard-section-title { @apply flex items-center gap-2 font-monoDisplay text-sm font-black uppercase tracking-widest text-brand; }
.dashboard-reaction-list { @apply flex flex-wrap gap-2; }
.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-system-card { @apply flex flex-col gap-4; }
.dashboard-system-list { @apply flex flex-col gap-2.5; }
.dashboard-system-item { @apply flex items-center justify-between rounded-2xl border border-blue-50/50 bg-white/40 p-3.5 text-sm transition-[background-color] duration-150 hover:bg-white/60; }
.dashboard-system-info { @apply flex items-center gap-3; }
.dashboard-system-icon { @apply flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50/50 text-brand; }
.dashboard-system-label { @apply font-bold text-navy; }
.dashboard-system-value { @apply font-mono text-xs font-bold text-slatecopy; }
@media (max-width: 900px) {
.dashboard-layout { @apply overflow-visible pr-0; }
.dashboard-grid { @apply grid-cols-2; }
.dashboard-row { @apply grid-cols-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; }
}
@media (max-width: 600px) {
.dashboard-grid { @apply grid-cols-1; }
}
}

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -1,13 +1,13 @@
import { Menu, Tray, type MenuItemConstructorOptions } from "electron";
import { getAppStateSnapshot, isOnboardingCompleted } from "./app-state.js";
import { getAppStateSnapshot } from "./app-state.js";
import { createTrayIcon } from "./assets.js";
import { hideDefaultPet, isDefaultPetVisible, setDefaultPetPaused, showDefaultPet } from "./default-pet-controller.js";
import { quitOpenPets } from "./lifecycle.js";
import { info, openLogsFolder } from "./logger.js";
import { shellState, togglePaused } from "./state.js";
import { getUpdateStatus, openUpdateReleasePage } from "./update-checker.js";
import { openTaskWindow } from "./windows.js";
import { openControlCenterWindow } from "./windows.js";
let tray: Tray | null = null;
@ -34,16 +34,6 @@ export function refreshTrayMenu(): void {
const defaultPet = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId && !pet.broken) ?? state.pets.installed[0];
const defaultPetName = defaultPet?.displayName ?? "Built-in Pet";
const continueSetupItems = isOnboardingCompleted()
? []
: [
{
label: "Continue Setup...",
click: () => openTaskWindow("onboarding"),
},
{ type: "separator" as const },
];
const menu = Menu.buildFromTemplate([
{
label: "OpenPets",
@ -51,10 +41,9 @@ export function refreshTrayMenu(): void {
},
...createUpdateMenuItems(),
{ type: "separator" },
...continueSetupItems,
{
label: `Default Pet: ${defaultPetName}`,
click: () => openTaskWindow("pet-manager"),
click: () => openControlCenterWindow("pets"),
},
{
label: isDefaultPetVisible() ? "Hide Default Pet" : "Show Default Pet",
@ -81,19 +70,23 @@ export function refreshTrayMenu(): void {
{ type: "separator" },
{
label: "Manage Pets...",
click: () => openTaskWindow("pet-manager"),
click: () => openControlCenterWindow("pets"),
},
{
label: "Control Center...",
click: () => openControlCenterWindow(),
},
{
label: "Integrations...",
click: () => openTaskWindow("agent-setup"),
click: () => openControlCenterWindow("integrations"),
},
{
label: "Plugins...",
click: () => openTaskWindow("plugins"),
click: () => openControlCenterWindow("plugins"),
},
{
label: "Settings...",
click: () => openTaskWindow("settings"),
click: () => openControlCenterWindow("settings"),
},
{
label: "Open Logs Folder...",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
module.exports = {
content: ["./src/renderer/**/*.{ts,tsx,html}"],
theme: {
extend: {
colors: {
navy: "#102149",
slatecopy: "#63708f",
brand: { DEFAULT: "#176df2", light: "#3b96ff" },
},
fontFamily: {
monoDisplay: ['"SFMono-Regular"', '"Cascadia Code"', '"Roboto Mono"', "monospace"],
},
boxShadow: {
glass: "0 24px 70px rgba(50, 104, 180, 0.18)",
},
},
},
};

View file

@ -5,12 +5,13 @@ import { fileURLToPath } from "node:url";
const desktopRoot = process.env.OPENPETS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
const windowsSource = readFileSync(resolve(desktopRoot, "src/windows.ts"), "utf8");
const preloadSource = readFileSync(resolve(desktopRoot, "preload.cjs"), "utf8");
const controlCenterPreloadSource = readFileSync(resolve(desktopRoot, "control-center-preload.cjs"), "utf8");
const controlCenterRendererSource = readFileSync(resolve(desktopRoot, "src/renderer/src/main.tsx"), "utf8");
const jsHostSource = readFileSync(resolve(desktopRoot, "src/plugin-js-host.ts"), "utf8");
const pluginSdkPreloadSource = readFileSync(resolve(desktopRoot, "plugin-sdk-preload.cjs"), "utf8");
assert.match(windowsSource, /kind === "plugins"[\s\S]*preload: getPreloadPath\(\)/);
assert.match(windowsSource, /assertAllowedSender\(event, \["plugins"\]\)/);
assert.doesNotMatch(windowsSource, /openTaskWindow|TaskWindowKind|createPluginsHtml|getPreloadPath|"preload\.cjs"/);
assert.match(windowsSource, /assertAllowedSender\(event, \["control-center"\]\)/);
assert.match(windowsSource, /openpets:plugins-snapshot/);
assert.match(windowsSource, /openpets:plugins-save-config/);
assert.match(windowsSource, /openpets:plugins-load-local/);
@ -18,33 +19,27 @@ assert.match(windowsSource, /openpets:plugins-catalog-snapshot/);
assert.match(windowsSource, /openpets:plugins-install-catalog/);
assert.match(windowsSource, /openpets:plugins-update-catalog/);
assert.match(windowsSource, /openpets:plugins-uninstall/);
assert.match(windowsSource, /openpets:plugins-load-local[\s\S]*assertAllowedSender\(event, \["plugins"\]\)/);
assert.match(preloadSource, /contextBridge\.exposeInMainWorld\("openpetsPlugins", pluginsApi\)/);
assert.match(preloadSource, /snapshot: \(\) => ipcRenderer\.invoke\("openpets:plugins-snapshot"\)/);
assert.match(preloadSource, /loadLocal: \(\) => ipcRenderer\.invoke\("openpets:plugins-load-local"\)/);
assert.match(preloadSource, /catalogSnapshot: \(refresh\) => ipcRenderer\.invoke\("openpets:plugins-catalog-snapshot", refresh\)/);
assert.match(preloadSource, /installCatalog: \(id\) => ipcRenderer\.invoke\("openpets:plugins-install-catalog", id\)/);
assert.match(preloadSource, /updateCatalog: \(id\) => ipcRenderer\.invoke\("openpets:plugins-update-catalog", id\)/);
assert.match(preloadSource, /uninstall: \(id\) => ipcRenderer\.invoke\("openpets:plugins-uninstall", id\)/);
assert.doesNotMatch(preloadSource, /plugins-load-local",\s*[^)]/);
assert.doesNotMatch(preloadSource, /manifestPath/);
assert.doesNotMatch(preloadSource, /installPath/);
assert.doesNotMatch(preloadSource, /openpets:plugins-install-catalog",\s*[^)]+,\s*[^)]/);
assert.match(windowsSource, /openpets:plugins-load-local[\s\S]*assertAllowedSender\(event, \["control-center"\]\)/);
// Ensure no plugin tabs remain
assert.doesNotMatch(preloadSource, /plugin-tabs/);
assert.doesNotMatch(preloadSource, /plugin-tab-content/);
assert.match(controlCenterPreloadSource, /getPluginsSnapshot: \(\) => ipcRenderer\.invoke\("openpets:plugins-snapshot"\)/);
assert.match(controlCenterPreloadSource, /getPluginCatalogSnapshot: \(refresh\) => ipcRenderer\.invoke\("openpets:plugins-catalog-snapshot", refresh\)/);
assert.match(controlCenterPreloadSource, /setPluginEnabled: \(id, enabled\) => ipcRenderer\.invoke\("openpets:plugins-set-enabled", id, enabled\)/);
assert.match(controlCenterPreloadSource, /savePluginConfig: \(id, config\) => ipcRenderer\.invoke\("openpets:plugins-save-config", id, config\)/);
assert.match(controlCenterPreloadSource, /loadLocalPlugin: \(\) => ipcRenderer\.invoke\("openpets:plugins-load-local"\)/);
assert.match(controlCenterPreloadSource, /installCatalogPlugin: \(id\) => ipcRenderer\.invoke\("openpets:plugins-install-catalog", id\)/);
assert.match(controlCenterPreloadSource, /uninstallPlugin: \(id\) => ipcRenderer\.invoke\("openpets:plugins-uninstall", id\)/);
assert.doesNotMatch(controlCenterPreloadSource, /onboarding/i);
assert.doesNotMatch(controlCenterPreloadSource, /plugins-load-local",\s*[^)]/);
assert.doesNotMatch(controlCenterPreloadSource, /manifestPath/);
assert.doesNotMatch(controlCenterPreloadSource, /installPath/);
assert.doesNotMatch(controlCenterPreloadSource, /openpets:plugins-install-catalog",\s*[^)]+,\s*[^)]/);
// Ensure developer load button is bound
assert.match(preloadSource, /loadLocalBtn\.onclick/);
// Ensure option object label/value handling exists
assert.match(preloadSource, /opt\.value/);
assert.match(preloadSource, /opt\.label/);
// Ensure list item collection avoids nested data loss
assert.match(preloadSource, /dataset\.schemaKey/);
assert.match(preloadSource, /Array\.from\(container\.children\)/);
assert.match(controlCenterRendererSource, /function PluginsView\(\)/);
assert.match(controlCenterRendererSource, /currentRoute === "plugins"[\s\S]*<PluginsView \/>/);
assert.doesNotMatch(controlCenterRendererSource, /OnboardingView|currentRoute === "onboarding"/);
assert.match(controlCenterRendererSource, /materializeListItemDefaults/);
assert.match(controlCenterRendererSource, /updateCatalogEntry[\s\S]*api\.updateCatalogPlugin/);
assert.match(controlCenterRendererSource, /installed\.source === "catalog"[\s\S]*updateCatalogEntry/);
assert.match(jsHostSource, /OpenPetsPlugin[\s\S]*register/);
assert.match(jsHostSource, /start\(sdk\)/);
@ -54,4 +49,4 @@ assert.match(pluginSdkPreloadSource, /contextBridge\.exposeInMainWorld\("__openP
assert.match(pluginSdkPreloadSource, /speak: \(message\) => call\("pet\.speak", \[message\]\)/);
assert.match(pluginSdkPreloadSource, /register: \(command, handler\) => call\("commands\.register"/);
console.error("Plugin UI static validation passed.");
console.error("Plugin Control Center static validation passed.");

View file

@ -1,15 +0,0 @@
import assert from "node:assert/strict";
import { createPluginsHtml } from "../src/plugins-window.js";
const html = createPluginsHtml({ title: "Plugins", heading: "Plugins", description: "Manage plugins." });
assert.match(html, /data-openpets-view="plugins"/);
assert.match(html, /Content-Security-Policy/);
assert.doesNotMatch(html, /script-src/);
assert.doesNotMatch(html, /https:\/\//);
assert.match(html, /id="plugins-grid"/);
assert.match(html, /id="detail-view"/);
assert.match(html, /id="hub-view"/);
console.error("Plugins window validation passed.");

View file

@ -4,5 +4,6 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts"],
"exclude": ["src/renderer/**"]
}

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": "src/renderer",
"outDir": "dist/renderer-types",
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["vite/client", "react", "react-dom"]
},
"include": ["src/renderer/**/*.ts", "src/renderer/**/*.tsx", "src/renderer/**/*.d.ts"]
}

View file

@ -9,5 +9,5 @@
"lib": ["ES2022"]
},
"include": ["tests/**/*.ts", "contracts/**/*.ts", "src/**/*.ts"],
"exclude": ["dist", ".test-dist", "node_modules"]
"exclude": ["dist", ".test-dist", "node_modules", "src/renderer/**"]
}

View file

@ -0,0 +1,23 @@
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'";
export default defineConfig(({ command }) => ({
root: "src/renderer",
base: "./",
plugins: [
react(),
command === "serve" && {
name: "openpets-dev-csp",
transformIndexHtml(html: string) {
return html.replace(productionCsp, devCsp);
},
},
].filter(Boolean),
build: {
outDir: "../../dist/renderer",
emptyOutDir: true,
},
}));

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

View file

@ -23,6 +23,8 @@ OpenPets is a pnpm/TypeScript monorepo for an Electron desktop companion app plu
| `apps/desktop/` | User-facing Electron companion app: tray UX, pet windows, pet installation, plugin automation/runtime, agent setup, update checks, and local IPC server. | [View Map](apps/desktop/codemap.md) |
| `apps/desktop/contracts/` | Desktop public-boundary contract tests for catalog fixtures, local IPC protocol behavior, and plugin manifest schema validation. | [View Map](apps/desktop/contracts/codemap.md) |
| `apps/desktop/src/` | Main-process service layer for app lifecycle, state, tray/windows, IPC routing, lease-managed agent pets, catalog installation, plugin subsystem, and editor integration. | [View Map](apps/desktop/src/codemap.md) |
| `apps/desktop/src/renderer/` | Vite renderer workspace for the sandboxed React/Tailwind Control Center bundle. | [View Map](apps/desktop/src/renderer/codemap.md) |
| `apps/desktop/src/renderer/src/` | React/Tailwind Control Center UI for Dashboard, Pets, Integrations, Plugins, and Settings via narrow preload APIs. | [View Map](apps/desktop/src/renderer/src/codemap.md) |
| `apps/desktop/scripts/` | Desktop package cleanup and local release automation scripts. | [View Map](apps/desktop/scripts/codemap.md) |
| `packages/` | Publishable npm package workspace for shared protocol, CLI, MCP, and coding-agent integrations. | [View Map](packages/codemap.md) |
| `packages/agent-events/` | Shared agent speech/event message pools and validation utilities. | [View Map](packages/agent-events/codemap.md) |

326
docs/front_refactor.md Normal file
View file

@ -0,0 +1,326 @@
# Desktop Frontend Refactor
## Current Situation
The desktop app now uses the React Control Center as the production management UI. Pets, Settings, Plugins, and Integrations are routed inside the singleton Control Center window, and tray actions target those routes directly.
The legacy generated task-window UI has been removed: `apps/desktop/preload.cjs`, `apps/desktop/src/plugins-window.ts`, and the legacy plugins-window test are gone. Transparent pet windows and plugin SDK windows remain separate lightweight renderers.
## Goal
Move the desktop management UI from generated HTML/CSS strings and preload-driven DOM updates to a React + Tailwind Control Center, while keeping desktop pet windows lightweight and separate.
## End Goal
The end goal is a single polished desktop management experience for OpenPets:
```text
Tray app
├─ animated desktop pet windows
└─ one React/Tailwind Control Center
├─ Dashboard
├─ Pets
├─ Integrations
├─ Plugins
└─ Settings
```
The Control Center should become the one place where users manage everything:
- choose and install pets
- configure coding-agent integrations
- manage plugins
- tune settings
- inspect status/errors/update prompts
The old separate task windows should eventually disappear. The tray should still exist, but tray actions should route into the Control Center instead of opening independent windows.
The pet windows themselves should stay separate from React. They are performance- and behavior-sensitive transparent desktop windows, so they should remain small, focused renderers responsible only for showing pets, speech bubbles, and reactions.
Final architecture target:
- **Main process**: app lifecycle, tray, state, IPC handlers, services, pet/plugin/integration logic.
- **Control Center renderer**: React UI, routes, forms, visual polish, local UI state.
- **Preloads**: narrow typed bridges only, no DOM rendering.
- **Pet renderers**: lightweight sprite/speech renderers, not part of Control Center.
From a product perspective, the end state should feel like a clean native-quality companion app rather than a set of separate utility dialogs.
## Current Direction
- Keep the app tray-first.
- Keep existing transparent pet windows outside React.
- Use a single Control Center window for management UI.
- Route tray actions into Control Center pages instead of independent task windows.
- Keep transparent pet windows separate from React.
## Phase 1: Pets Preview
Implemented first as a reviewable prototype and now promoted into the primary management shell:
- `Control Center...` opens the React renderer.
- Pet management opens through the Control Center Pets route.
- Renderer lives under `apps/desktop/src/renderer/`.
- Vite outputs packaged assets to `apps/desktop/dist/renderer/`.
- `control-center-preload.cjs` exposes narrow page-specific APIs.
The Pets page includes:
- installed pets
- catalog pets
- catalog pagination
- catalog search
- Codex pets
- pet selection/detail pane
- install/import/set default/remove actions
- animated sprite-frame preview
- styling based on the existing Pet Manager and Integrations pages
## What We Changed
### Renderer foundation
Added a modern renderer stack for the desktop app:
- React
- ReactDOM
- Vite
- TailwindCSS
- PostCSS
- Autoprefixer
New renderer files live in:
```text
apps/desktop/src/renderer/
```
Production renderer output is built to:
```text
apps/desktop/dist/renderer/
```
### Control Center window
Added a dedicated Control Center BrowserWindow in `apps/desktop/src/windows.ts`.
Important details:
- It is a separate singleton window.
- It does not use the old task-window data URL system.
- It keeps the usual Electron hardening:
- sandbox enabled
- context isolation enabled
- Node integration disabled
- navigation blocked
- `window.open` blocked
- In development, it can load `OPENPETS_RENDERER_URL`, but only from loopback hosts.
- In packaged builds, it loads `dist/renderer/index.html`.
### Tray integration
Tray items now route to the Control Center singleton:
- `Control Center...`
- `Manage Pets...` → Pets route
- Plugins, Integrations, and Settings → matching Control Center routes
### Narrow preload bridge
Added:
```text
apps/desktop/control-center-preload.cjs
```
It exposes narrow, page-specific methods to the React renderer. It now covers Pets, Settings, Plugins, and Integrations without exposing `ipcRenderer` directly.
Pets methods include:
- `getPetsState`
- `getCatalog`
- `getCatalogPage`
- `getCatalogSearch`
- `getCodexPets`
- `setDefaultPet`
- `installPet`
- `importCodexPet`
- `removePet`
Settings methods include startup preferences, launch-at-login, pet scale, reaction animation mapping, update checks, and default pet position reset.
Plugins methods include plugin snapshots, catalog snapshots, enable/disable, config save, reload, command execution, local plugin loading, catalog install/update, and uninstall.
Integrations methods include agent setup snapshots, setup actions, and command path updates for Claude Code, OpenCode, and Cursor.
It does not expose `ipcRenderer` directly.
### Narrow page state
The Control Center does not receive the full app state. It receives narrowed page snapshots only.
The Pets page receives:
- default pet id
- installed pets
The Settings page receives only settings preferences/options needed by the route.
The Plugins page receives safe plugin and catalog records from `PluginService`, intentionally excluding raw install paths and manifest paths.
The Integrations page receives the existing narrowed agent setup snapshot used by the legacy task window: status/details for Claude Code, OpenCode, Cursor, Pi guidance, command paths, pet routing choices, and safe previews.
This avoids exposing unrelated state to the new renderer.
### Pets page UI
The new Pets page now supports:
- installed pet list
- remote catalog pets
- catalog pagination
- catalog search
- Codex pets
- search/filter UI
- set default
- install catalog pet
- import Codex pet
- remove pet
- detail panel
- animated sprite-frame preview
### Settings page UI
The Control Center Settings page now supports:
- show-pet-on-launch preference
- launch-at-login preference
- pet scale selection
- reaction-to-animation mapping
- default pet position reset
- compact system/update status
- default-pet reaction previews using the `openpets-pet-preview:` protocol
- bottom-center floating notifications that do not shift layout
Settings is now served by the Control Center route.
### Plugins page UI
The Control Center Plugins page now supports:
- gallery-first plugin hub
- installed/catalog/local/broken filters
- catalog refresh and local plugin loading in the bottom utility row
- plugin install from catalog
- enable/disable directly from plugin cards
- on-demand configuration modal instead of a persistent split inspector
- dynamic config forms for supported plugin schema fields
- command execution when plugins expose commands
- reload, update, and uninstall actions
- safe no-op/cancel feedback for install/update/local-load flows
Plugins is now served by the Control Center route; broken plugins cannot be re-enabled until fixed.
### Integrations page UI
The Control Center Integrations page now supports:
- grid-first integration hub with icons and status pills
- direct install/connect actions from Claude Code, OpenCode, and Cursor cards
- detail inspector opened by Configure/View setup buttons
- Claude MCP setup, replace/remove, hooks, instructions, pet routing, command paths, and advanced previews
- OpenCode global setup, remove, pet routing, command paths, and config preview
- Cursor MCP setup, replace/remove, pet routing, MCP preview, and Cursor rules preview
- Pi manual setup guidance
- polished disabled cards for future editor integrations
Integrations is now served by the Control Center route, including command-source selection for published, bundled, or local development CLI modes.
### Dashboard UI
The Control Center Dashboard now supports:
- default companion overview with animated sprite preview
- quick navigation into pet selection
- message and reaction activity totals
- top companion and top reaction summaries
- last-interaction timestamp
- compact system health for installed pets, plugins, catalog, updates, and app version
Dashboard is now the default Control Center landing route.
### Build/test integration
Updated desktop scripts/configs so the new renderer participates in normal validation.
Important files:
- `apps/desktop/package.json`
- `apps/desktop/vite.config.ts`
- `apps/desktop/tailwind.config.cjs`
- `apps/desktop/postcss.config.cjs`
- `apps/desktop/tsconfig.renderer.json`
- `apps/desktop/electron-builder.yml`
- `apps/desktop/scripts/run-tests.mjs`
Validated with:
```bash
pnpm --filter @open-pets/desktop typecheck
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
node --check apps/desktop/control-center-preload.cjs
```
All passed.
## Styling Principles
Reuse the current polished desktop visual language:
- light blue gradient background
- white glass cards
- soft blue shadows
- navy text
- brand blue CTAs
- rounded mono buttons and status pills
- orange Originals filter accent
- two-column gallery/detail layout
## Security Constraints
- `nodeIntegration: false`
- `contextIsolation: true`
- `sandbox: true`
- no direct `ipcRenderer` exposure
- deny navigation, redirects, and `window.open`
- dev renderer URL allowed only for loopback hosts in non-packaged builds
- Control Center receives narrow page snapshots, not full app state
## Migration Status and Next Order
Completed phases:
- Manual Pets review and refinement
- Control Center routing/shell
- Settings migration
- Plugins migration
- Integrations migration
- Tray route switch to Control Center
- Legacy generated task-window UI removal
Remaining follow-up:
- Continue manual smoke coverage for tray route retargeting, plugin workflows, and integration setup flows.
## Validation Commands
Use these after each phase:
```bash
pnpm --filter @open-pets/desktop typecheck
pnpm --filter @open-pets/desktop build
pnpm --filter @open-pets/desktop test
node --check apps/desktop/control-center-preload.cjs
```

View file

@ -15,6 +15,7 @@
},
"scripts": {
"dev:desktop": "pnpm --filter @open-pets/desktop dev",
"dev:desktop:control-center": "pnpm --filter @open-pets/desktop dev:control-center",
"dev:desktop:plugins": "pnpm --filter @open-pets/desktop dev:plugins",
"package:desktop:dir": "pnpm build && pnpm --filter @open-pets/desktop package:dir",
"package:desktop": "pnpm build && pnpm --filter @open-pets/desktop package",

View file

@ -13,6 +13,7 @@ Provides modular, reusable components for the OpenPets ecosystem:
- **opencode**: OpenCode editor integration (plugin, config management)
- **claude**: Claude Code integration (hooks, MCP config)
- **cursor**: Cursor editor integration (MCP config, project rules)
- **pi**: Pi coding-agent extension integration (event hooks, slash commands)
- **install-pet**: Standalone pet installer from gallery catalog
## Design/Patterns
@ -28,7 +29,7 @@ Provides modular, reusable components for the OpenPets ecosystem:
**ESM-First**: All packages are ESM (`"type": "module"`) with dual exports for types.
**Versioning**: Independent versioning per package (all currently 2.0.x).
**Versioning**: Independent versioning per package (currently 2.1.x for active integrations).
## Flow
@ -52,6 +53,9 @@ Claude Hooks (packages/claude/src/hooks.ts)
Cursor Setup (packages/cursor/src/cursor-project-setup.ts)
└── Writes MCP config + rules → @open-pets/client
Pi Extension (packages/pi/src/extension.ts)
└── Registers Pi extension hooks/commands → @open-pets/client
```
## Integration Points
@ -62,6 +66,7 @@ Cursor Setup (packages/cursor/src/cursor-project-setup.ts)
- `claude` depends on: `client`, `agent-events`
- `opencode` depends on: `client`, `agent-events`
- `cursor` depends on: `client`
- `pi` depends on: `client`, `agent-events` and declares optional `@earendil-works/pi-coding-agent` peer support
- `install-pet` depends on: `client`
**External Integrations**:

View file

@ -4,7 +4,7 @@ OpenCode editor integration for OpenPets.
## Responsibility
Provides comprehensive OpenCode editor integration including: MCP server configuration, plugin runtime with event hooks, project/global setup management, and instruction file generation.
Provides comprehensive OpenCode editor integration including: MCP server configuration, plugin runtime with event hooks, project/global setup management/removal, and instruction file generation.
## Design
@ -36,8 +36,10 @@ Provides comprehensive OpenCode editor integration including: MCP server configu
**Global Setup** (`opencode-global-setup.ts`):
- Similar to project setup but for `~/.config/opencode/`
- Cleanup writes: Removes duplicate entries from other config files
- Setup cleanup writes: Removes managed duplicate entries from other config files
- Remove support: `prepareOpenCodeGlobalRemove()` / `writePreparedOpenCodeGlobalRemove()` remove managed MCP, instruction, and plugin entries plus the managed instruction block
- Doctor command: `doctorOpenCodeGlobalSetup()` for status checking
- Config precedence handling: chooses the effective config file across `config.json`, `opencode.json`, and `opencode.jsonc`, preserving existing user arrays when safe
**Status Classification** (`opencode-status.ts`):
- MCP entry detection: `isManagedOpenPetsMcpEntry()`
@ -50,6 +52,7 @@ Provides comprehensive OpenCode editor integration including: MCP server configu
- Plugin spec builder: `buildOpenCodePluginPreview()`
- Instruction path builder: `buildOpenCodeInstructionPath()`
- Pet ID validation: `validateOpenPetsPetArg()`
- MCP config formatter: `formatOpenCodeMcpConfig()`
## Flow
@ -80,6 +83,11 @@ writePreparedOpenCodeProjectSetup() → Execute writes atomically
- `@open-pets/agent-events` - Speech pools and validation
- `jsonc-parser` - JSONC config parsing and editing
**Package Surface**:
- Package version: `2.1.1`
- Main export (`.`): setup, config, preview, status, and runtime APIs from `dist/index.js`
- Server export (`./server`): OpenCode plugin default export from `dist/plugin.js`
**Consumers**:
- `@open-pets/cli` - `configure` command for OpenCode projects
@ -87,4 +95,5 @@ writePreparedOpenCodeProjectSetup() → Execute writes atomically
- `plugin.ts` - Default plugin export for OpenCode
- `prepareOpenCodeProjectSetup()`, `writePreparedOpenCodeProjectSetup()`
- `prepareOpenCodeGlobalSetup()`, `writePreparedOpenCodeGlobalSetup()`
- `prepareOpenCodeGlobalRemove()`, `writePreparedOpenCodeGlobalRemove()`
- Config management utilities

View file

@ -7,8 +7,8 @@
- **opencode-plugin-runtime.ts**: Plugin hook implementations (229 lines). `createOpenPetsOpenCodeHooks()`, event classification, tool reaction mapping, lease management, throttling.
- **opencode-config.ts**: Config file management (221 lines). Path resolution, JSONC parsing, safe file operations, atomic writes with backups.
- **opencode-project-setup.ts**: Project-level setup (182 lines). `prepareOpenCodeProjectSetup()`, `writePreparedOpenCodeProjectSetup()`, instruction block management.
- **opencode-global-setup.ts**: Global setup management (354 lines). `prepareOpenCodeGlobalSetup()`, cleanup writes, doctor command, global state classification.
- **opencode-status.ts**: Status classification (136 lines). `classifyOpenCodeMcpStatus()`, `classifyOpenCodeInstructionsStatus()`, `classifyOpenCodePluginStatus()`, entry detection functions.
- **opencode-previews.ts**: Config entry builders (52 lines). `buildOpenCodeMcpEntry()`, `buildOpenCodePluginPreview()`, `buildOpenCodeInstructionPath()`, `validateOpenPetsPetArg()`.
- **opencode-global-setup.ts**: Global setup management (354 lines). `prepareOpenCodeGlobalSetup()`, `prepareOpenCodeGlobalRemove()`, cleanup writes, doctor command, config precedence handling, global state classification.
- **opencode-status.ts**: Status classification (147 lines). `classifyOpenCodeMcpStatus()`, `classifyOpenCodeInstructionsStatus()`, `classifyOpenCodePluginStatus()`, managed MCP/plugin/instruction detection helpers, OpenPets-like entry detection.
- **opencode-previews.ts**: Config entry builders (55 lines). `buildOpenCodeMcpEntry()`, `buildOpenCodePluginPreview()`, `buildOpenCodeInstructionPath()`, `formatOpenCodeMcpConfig()`, `validateOpenPetsPetArg()`.
- **check-opencode-foundation.ts**: Contract validation (excluded from detailed documentation).
- **check-opencode-plugin.ts**: Plugin contract validation (excluded from detailed documentation).

1234
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff