Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions docs/superpowers/plans/2026-07-29-petdex-import-and-pet-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Petdex Import and Pet Size Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship automatic Petdex ZIP handoff and persistent three-level pet sizing as desktop release 0.2.4.

**Architecture:** Electron owns the isolated remote browser and temporary download. The renderer reuses the existing ZIP preview pipeline. A schema-backed size preference feeds one shared viewport sizing function used by both pet renderers.

**Tech Stack:** Electron 35, React 19, TypeScript, Vitest, Playwright

## Global Constraints

- Medium must preserve the current desktop pet size.
- Petdex downloads must still require import-preview confirmation.
- Existing manual ZIP and loose-file imports remain available.
- Release version is `0.2.4`.

---

### Task 1: Petdex download bridge

**Files:**
- Create: `electron/petdex-download.js`
- Modify: `electron/main.js`
- Modify: `electron/preload.js`
- Modify: `src/types/electron-api.d.ts`
- Modify: `src/features/pets/components/PetLibrary.tsx`
- Test: `electron/petdex-download.test.js`
- Test: `electron/main.test.js`
- Test: `src/features/pets/components/PetLibrary.test.tsx`

- [x] Define and test the Petdex origin, ZIP, and size policies.
- [x] Add the sandboxed Petdex browser and temporary download bridge.
- [x] Feed intercepted archives into the existing preview flow.
- [ ] Run focused Electron and pet-library tests.

### Task 2: Persistent pet sizing

**Files:**
- Modify: `src/app/model.ts`
- Modify: `src/app/defaults.ts`
- Modify: `src/infrastructure/settingsRepository.ts`
- Modify: `src/features/cat/stage/viewport.ts`
- Modify: `src/features/cat/components/InteractiveCatStage.tsx`
- Modify: `src/features/pets/components/CodexPetStage.tsx`
- Modify: `src/features/settings/SettingsPage.tsx`
- Modify: `src/i18n/messages.ts`

- [x] Migrate settings schema 5 with medium as the fallback.
- [x] Add localized small, medium, and large controls.
- [x] Apply shared dimensions to both pet renderers and their interaction geometry.
- [ ] Run focused persistence, viewport, and settings tests.

### Task 3: Release

- [ ] Run `npm run check:desktop-release`.
- [ ] Commit, review, merge, tag `v0.2.4`, and verify all five release assets.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Petdex Import and Pet Size Design

## Goal

Remove the manual ZIP handoff after downloading a pet from Petdex and add a
persistent small, medium, or large desktop-pet size preference.

## Petdex flow

The pet library opens `https://petdex.dev/` in a sandboxed Electron window with
Node.js disabled. Electron intercepts ZIP downloads, stores each archive in the
application temporary directory, enforces the existing 32 MiB limit, forwards
the bytes to the settings renderer, and deletes the temporary file.

The renderer feeds the ZIP into the existing archive extraction and validation
pipeline. Automatic import stops at the existing preview; saving or replacing a
pet still requires explicit user confirmation.

## Size flow

Settings schema version 5 adds `petSize` with `small`, `medium`, and `large`.
Legacy settings migrate to `medium`. Desktop sizes are 80%, 100%, and 125% of
the existing 140 by 152 pixel pet. Both the built-in cat and imported pets use
the same sizing function for rendering, hit testing, dragging, and reminder
bubble anchoring.

## Security

- The Petdex window has `sandbox: true`, `nodeIntegration: false`, and no preload.
- Only ZIP downloads enter the import bridge.
- Existing ZIP path, count, expanded-size, encryption, atlas, and manifest checks remain authoritative.
- Imported pets are never saved without the existing preview confirmation.
3 changes: 2 additions & 1 deletion e2e/helpers/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export async function seedApp(
);
const enabled = new Map(presetReminders.map((item) => [item.type, item]));
localStorage.setItem('neko-pause:settings', JSON.stringify({
schemaVersion: 4,
schemaVersion: 5,
petSize: 'medium',
locale: seededOptions.locale ?? 'zh-CN',
onboardingComplete: true,
theme: seededOptions.theme ?? 'light',
Expand Down
99 changes: 99 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
app,
BrowserWindow,
Menu,
screen,
shell,
Tray,
nativeImage,
ipcMain,
} from 'electron';
import { resolvePetWindowPolicy } from './window-policy.js';
import {
isAllowedPetdexNavigation,
isSupportedPetArchive,
MAX_PETDEX_ARCHIVE_BYTES,
PETDEX_URL,
} from './petdex-download.js';

const IS_DEV = process.argv.includes('--dev') || process.env.NODE_ENV === 'development';
const EDGE_HANDLE_SIZE = 24;
Expand Down Expand Up @@ -39,6 +47,7 @@ const stateFilePath = path.join(app.getPath('userData'), STATE_FILE);

let petWindow;
let settingsWindow;
let petdexWindow;
let tray;
let dockState = {
isDocked: false,
Expand Down Expand Up @@ -353,6 +362,13 @@ function registerIpcHandlers() {
openSettingsWindow();
});

ipcMain.handle('pet:open-petdex', (event) => {
if (settingsWindow === undefined
|| settingsWindow.isDestroyed()
|| event.sender.id !== settingsWindow.webContents.id) return;
openPetdexWindow();
});

ipcMain.handle('pet:manual-dock', () => {
if (!EDGE_DOCKING_ENABLED) return;
const currentPetWindow = getCurrentPetWindow();
Expand Down Expand Up @@ -380,6 +396,88 @@ function registerIpcHandlers() {
});
}

function sendPetdexImport(payload) {
if (settingsWindow === undefined || settingsWindow.isDestroyed()) return;
settingsWindow.webContents.send('pet:petdex-import', payload);
}

function handlePetdexDownload(_event, item) {
const filename = path.basename(item.getFilename());
if (!isSupportedPetArchive(filename, item.getMimeType(), item.getURL())) return;
const totalBytes = item.getTotalBytes();
if (totalBytes > MAX_PETDEX_ARCHIVE_BYTES) {
item.cancel();
sendPetdexImport({ type: 'error' });
return;
}

const temporaryPath = path.join(app.getPath('temp'), `codex-pet-pause-${randomUUID()}.zip`);
item.setSavePath(temporaryPath);
item.once('done', (_doneEvent, state) => {
if (state !== 'completed') {
void fs.promises.rm(temporaryPath, { force: true });
sendPetdexImport({ type: 'error' });
return;
}
void (async () => {
try {
const stat = await fs.promises.stat(temporaryPath);
if (stat.size > MAX_PETDEX_ARCHIVE_BYTES) {
sendPetdexImport({ type: 'error' });
return;
}
const bytes = await fs.promises.readFile(temporaryPath);
const transferable = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
);
sendPetdexImport({ type: 'archive', name: filename || 'petdex-pet.zip', bytes: transferable });
} catch {
sendPetdexImport({ type: 'error' });
} finally {
await fs.promises.rm(temporaryPath, { force: true });
}
})();
});
}

function openPetdexWindow() {
if (petdexWindow !== undefined && !petdexWindow.isDestroyed()) {
if (petdexWindow.isMinimized()) petdexWindow.restore();
petdexWindow.focus();
return;
}

petdexWindow = new BrowserWindow({
width: 1100,
height: 760,
title: 'Petdex',
parent: settingsWindow,
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: 'persist:petdex',
},
});
const petdexSession = petdexWindow.webContents.session;
petdexSession.on('will-download', handlePetdexDownload);
petdexWindow.webContents.setWindowOpenHandler(({ url }) => {
if (isAllowedPetdexNavigation(url)) {
void petdexWindow?.loadURL(url);
} else if (url.startsWith('https://')) {
void shell.openExternal(url);
}
return { action: 'deny' };
});
petdexWindow.loadURL(PETDEX_URL);
petdexWindow.on('closed', () => {
petdexSession.removeListener('will-download', handlePetdexDownload);
petdexWindow = undefined;
});
}

function createWindow() {
const workspace = getPrimaryWorkArea();
const persisted = readPersistedState();
Expand Down Expand Up @@ -499,6 +597,7 @@ function openSettingsWindow() {

settingsWindow.loadURL(resolveWindowUrl('web&view=settings&hidePet=1'));
settingsWindow.on('closed', () => {
if (petdexWindow !== undefined && !petdexWindow.isDestroyed()) petdexWindow.close();
settingsWindow = undefined;
});
}
Expand Down
30 changes: 29 additions & 1 deletion electron/main.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,17 @@ const electron = vi.hoisted(() => {
minimized: false,
webContents: {
id: nextWebContentsId++,
send: vi.fn(),
setWindowOpenHandler: vi.fn(),
session: {
on: vi.fn(),
removeListener: vi.fn(),
},
},
close: vi.fn(() => {
window.destroyed = true;
listeners.get('closed')?.();
}),
focus: vi.fn(),
getBounds: vi.fn(() => ({
x: options.x ?? 0,
Expand Down Expand Up @@ -83,6 +93,9 @@ const electron = vi.hoisted(() => {
createEmpty: vi.fn(() => icon),
createFromPath: vi.fn(() => icon),
},
shell: {
openExternal: vi.fn(),
},
screen: {
getCursorScreenPoint: vi.fn(() => ({ x: 0, y: 0 })),
getDisplayMatching: vi.fn(() => ({
Expand Down Expand Up @@ -147,7 +160,7 @@ describe('Electron process lifecycle', () => {
activate();
const currentPetWindow = electron.windows.at(-1);

for (const channel of ['pet:open-settings', 'pet:manual-dock']) {
for (const channel of ['pet:open-settings', 'pet:open-petdex', 'pet:manual-dock']) {
const registrations = electron.ipcMain.handle.mock.calls
.filter(([registeredChannel]) => registeredChannel === channel);
expect(registrations).toHaveLength(1);
Expand Down Expand Up @@ -191,6 +204,21 @@ describe('Electron process lifecycle', () => {
openSettings();
const firstSettingsWindow = electron.windows.at(-1);
expect(firstSettingsWindow.options.focusable).toBeUndefined();
const openPetdex = electron.ipcMain.handle.mock.calls
.find(([channel]) => channel === 'pet:open-petdex')[1];
openPetdex({ sender: firstSettingsWindow.webContents });
const firstPetdexWindow = electron.windows.at(-1);
expect(firstPetdexWindow.options).toMatchObject({
title: 'Petdex',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: 'persist:petdex',
},
});
expect(firstPetdexWindow.webContents.session.on)
.toHaveBeenCalledWith('will-download', expect.any(Function));
firstSettingsWindow.listeners.get('closed')();
openSettings();

Expand Down
27 changes: 27 additions & 0 deletions electron/petdex-download.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export const PETDEX_URL = 'https://petdex.dev/';
export const MAX_PETDEX_ARCHIVE_BYTES = 32 * 1024 * 1024;

export function isAllowedPetdexNavigation(rawUrl) {
try {
const url = new URL(rawUrl);
return url.protocol === 'https:'
&& (url.hostname === 'petdex.dev' || url.hostname.endsWith('.petdex.dev'));
} catch {
return false;
}
}

export function isSupportedPetArchive(filename, mimeType = '', rawUrl = '') {
const normalizedFilename = String(filename ?? '').toLowerCase();
const normalizedMime = String(mimeType ?? '').toLowerCase();
let pathname = '';
try {
pathname = new URL(rawUrl).pathname.toLowerCase();
} catch {
pathname = '';
}
return normalizedFilename.endsWith('.zip')
|| pathname.endsWith('.zip')
|| normalizedMime === 'application/zip'
|| normalizedMime === 'application/x-zip-compressed';
}
23 changes: 23 additions & 0 deletions electron/petdex-download.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import {
isAllowedPetdexNavigation,
isSupportedPetArchive,
MAX_PETDEX_ARCHIVE_BYTES,
} from './petdex-download.js';

describe('Petdex download policy', () => {
it('accepts Petdex HTTPS pages and rejects lookalike or unsafe origins', () => {
expect(isAllowedPetdexNavigation('https://petdex.dev/pets/boba')).toBe(true);
expect(isAllowedPetdexNavigation('https://cdn.petdex.dev/boba.zip')).toBe(true);
expect(isAllowedPetdexNavigation('https://petdex.dev.example.com/boba.zip')).toBe(false);
expect(isAllowedPetdexNavigation('http://petdex.dev/pets/boba')).toBe(false);
});

it('recognizes ZIP downloads from filename, URL, or MIME type', () => {
expect(isSupportedPetArchive('boba.zip')).toBe(true);
expect(isSupportedPetArchive('download', '', 'https://petdex.dev/files/boba.zip')).toBe(true);
expect(isSupportedPetArchive('download', 'application/zip')).toBe(true);
expect(isSupportedPetArchive('pet.json', 'application/json')).toBe(false);
expect(MAX_PETDEX_ARCHIVE_BYTES).toBe(32 * 1024 * 1024);
});
});
6 changes: 6 additions & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('petShell', {
openSettings: () => ipcRenderer.invoke('pet:open-settings'),
openPetdex: () => ipcRenderer.invoke('pet:open-petdex'),
onPetdexImport: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on('pet:petdex-import', listener);
return () => ipcRenderer.removeListener('pet:petdex-import', listener);
},
dockNow: () => ipcRenderer.invoke('pet:manual-dock'),
dragWindowTo: (x, y) => ipcRenderer.send('pet:drag-window', { x, y }),
showContextMenu: (x, y) => ipcRenderer.send('pet:show-context-menu', { x, y }),
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "codex-pet-pause",
"private": true,
"version": "0.2.3",
"version": "0.2.4",
"description": "A playful, local-first break reminder PWA with interactive and Codex-compatible pets.",
"license": "MIT",
"repository": {
Expand Down
Loading
Loading