claude-pet 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of claude-pet might be problematic. Click here for more details.
- package/.claude/commands/feed.md +28 -0
- package/.claude/commands/name.md +28 -0
- package/.claude/commands/pet.md +29 -0
- package/.claude/commands/play.md +29 -0
- package/.claude/settings.local.json +41 -0
- package/.github/workflows/AGENTS.md +60 -0
- package/.github/workflows/build.yml +87 -0
- package/AGENTS.md +66 -0
- package/LICENSE +15 -0
- package/README.md +292 -0
- package/bin/claude-pet.js +42 -0
- package/build/AGENTS.md +50 -0
- package/build/dmg-background.png +0 -0
- package/build/entitlements.mac.plist +14 -0
- package/build/icon.ico +0 -0
- package/build/icon.png +0 -0
- package/build/installerHeader.bmp +0 -0
- package/build/installerSidebar.bmp +0 -0
- package/build/tray-icon.png +0 -0
- package/dist/main/core/badge-manager.js +49 -0
- package/dist/main/core/badge-registry.js +72 -0
- package/dist/main/core/badge-triggers.js +45 -0
- package/dist/main/core/contextual-messages.js +372 -0
- package/dist/main/core/messages.js +440 -0
- package/dist/main/core/mood-engine.js +145 -0
- package/dist/main/core/pet-messages.js +612 -0
- package/dist/main/core/pet-state-engine.js +232 -0
- package/dist/main/core/quote-collection.js +60 -0
- package/dist/main/core/quote-registry.js +175 -0
- package/dist/main/core/quote-triggers.js +62 -0
- package/dist/main/core/usage-tracker.js +625 -0
- package/dist/main/main/auto-launch.js +39 -0
- package/dist/main/main/auto-updater.js +98 -0
- package/dist/main/main/event-watcher.js +174 -0
- package/dist/main/main/ipc-handlers.js +89 -0
- package/dist/main/main/main.js +422 -0
- package/dist/main/main/preload.js +93 -0
- package/dist/main/main/settings-window.js +49 -0
- package/dist/main/main/share-card.js +139 -0
- package/dist/main/main/skin-manager.js +118 -0
- package/dist/main/main/tray.js +88 -0
- package/dist/main/shared/i18n.js +392 -0
- package/dist/main/shared/types.js +25 -0
- package/dist/main/shared/utils.js +9 -0
- package/dist/renderer/assets/claude-pet.png +0 -0
- package/dist/renderer/assets/index-BMnMEuOf.js +9 -0
- package/dist/renderer/assets/index-qzlrlqpX.css +1 -0
- package/dist/renderer/index.html +30 -0
- package/dist/renderer/share-card-template/card.html +148 -0
- package/docs/AGENTS.md +42 -0
- package/docs/images/angry.png +0 -0
- package/docs/images/character.webp +0 -0
- package/docs/images/claude-mama.png +0 -0
- package/docs/images/happy.png +0 -0
- package/docs/images/proud.png +0 -0
- package/docs/images/share-card-example.png +0 -0
- package/docs/images/worried_1.png +0 -0
- package/docs/images/worried_2.png +0 -0
- package/docs/spritesheet-bugs.md +240 -0
- package/docs/superpowers/plans/2026-03-10-compact-widget.md +888 -0
- package/docs/superpowers/plans/2026-03-10-viral-features.md +1874 -0
- package/docs/superpowers/plans/2026-03-14-update-ux.md +362 -0
- package/docs/superpowers/plans/2026-03-14-v1.1-features.md +2139 -0
- package/docs/superpowers/specs/2026-03-10-compact-widget-design.md +150 -0
- package/docs/superpowers/specs/2026-03-10-viral-features-design.md +217 -0
- package/docs/superpowers/specs/2026-03-14-streak-calendar-design.md +26 -0
- package/docs/superpowers/specs/2026-03-14-update-ux-design.md +172 -0
- package/docs/superpowers/specs/2026-03-14-v1.1-features-design.md +342 -0
- package/electron-builder.yml +75 -0
- package/package.json +48 -0
- package/scripts/AGENTS.md +60 -0
- package/scripts/install.ps1 +47 -0
- package/scripts/install.sh +98 -0
- package/scripts/make-icon.js +119 -0
- package/scripts/notarize.js +18 -0
- package/src/AGENTS.md +47 -0
- package/src/core/AGENTS.md +58 -0
- package/src/core/__tests__/AGENTS.md +60 -0
- package/src/core/__tests__/badge-triggers.test.ts +83 -0
- package/src/core/__tests__/contextual-messages.test.ts +87 -0
- package/src/core/__tests__/pet-state-engine.test.ts +350 -0
- package/src/core/__tests__/quote-collection.test.ts +62 -0
- package/src/core/__tests__/quote-triggers.test.ts +110 -0
- package/src/core/badge-manager.ts +50 -0
- package/src/core/badge-registry.ts +71 -0
- package/src/core/badge-triggers.ts +41 -0
- package/src/core/contextual-messages.ts +381 -0
- package/src/core/pet-messages.ts +615 -0
- package/src/core/pet-state-engine.ts +272 -0
- package/src/core/quote-collection.ts +63 -0
- package/src/core/quote-registry.ts +181 -0
- package/src/core/quote-triggers.ts +64 -0
- package/src/core/usage-tracker.ts +680 -0
- package/src/main/AGENTS.md +70 -0
- package/src/main/auto-launch.ts +38 -0
- package/src/main/auto-updater.ts +106 -0
- package/src/main/event-watcher.ts +159 -0
- package/src/main/ipc-handlers.ts +107 -0
- package/src/main/main.ts +425 -0
- package/src/main/preload.ts +111 -0
- package/src/main/settings-window.ts +50 -0
- package/src/main/share-card.ts +153 -0
- package/src/main/skin-manager.ts +119 -0
- package/src/main/tray.ts +94 -0
- package/src/renderer/AGENTS.md +62 -0
- package/src/renderer/App.tsx +270 -0
- package/src/renderer/assets/claude-mama.png +0 -0
- package/src/renderer/assets/claude-pet.png +0 -0
- package/src/renderer/components/AGENTS.md +50 -0
- package/src/renderer/components/Character.tsx +327 -0
- package/src/renderer/components/SpeechBubble.tsx +182 -0
- package/src/renderer/components/UsageIndicator.tsx +268 -0
- package/src/renderer/electron.d.ts +34 -0
- package/src/renderer/hooks/AGENTS.md +55 -0
- package/src/renderer/hooks/usePetState.ts +59 -0
- package/src/renderer/hooks/useWidgetMode.ts +18 -0
- package/src/renderer/index.html +29 -0
- package/src/renderer/main.tsx +13 -0
- package/src/renderer/pages/AGENTS.md +53 -0
- package/src/renderer/pages/Collection.tsx +252 -0
- package/src/renderer/pages/Settings.tsx +815 -0
- package/src/renderer/share-card-template/card.html +148 -0
- package/src/renderer/styles/AGENTS.md +50 -0
- package/src/renderer/styles/styles.css +166 -0
- package/src/shared/AGENTS.md +48 -0
- package/src/shared/i18n.ts +395 -0
- package/src/shared/types.ts +163 -0
- package/src/shared/utils.ts +6 -0
- package/tsconfig.json +16 -0
- package/tsconfig.main.json +12 -0
- package/tsconfig.renderer.json +12 -0
- package/vite.config.ts +47 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { BrowserWindow, dialog, Notification, app } from 'electron';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { getStore } from './ipc-handlers';
|
|
5
|
+
import { t, DEFAULT_LOCALE } from '../shared/i18n';
|
|
6
|
+
import { PetState, Locale } from '../shared/types';
|
|
7
|
+
import { getQuoteById } from '../core/quote-registry';
|
|
8
|
+
|
|
9
|
+
const projectRoot = path.resolve(__dirname, '..', '..', '..');
|
|
10
|
+
|
|
11
|
+
let offscreenWin: BrowserWindow | null = null;
|
|
12
|
+
let isGenerating = false;
|
|
13
|
+
|
|
14
|
+
let currentPetState: PetState | null = null;
|
|
15
|
+
|
|
16
|
+
export function setShareCardState(state: PetState): void {
|
|
17
|
+
currentPetState = state;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MOOD_COLORS: Record<string, string> = {
|
|
21
|
+
happy: '#22c55e',
|
|
22
|
+
playful: '#f59e0b',
|
|
23
|
+
sleepy: '#6b7280',
|
|
24
|
+
worried: '#eab308',
|
|
25
|
+
bored: '#9ca3af',
|
|
26
|
+
confused: '#8b5cf6',
|
|
27
|
+
sleeping: '#6b7280',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function getOffscreenWindow(): BrowserWindow {
|
|
31
|
+
if (offscreenWin && !offscreenWin.isDestroyed()) {
|
|
32
|
+
return offscreenWin;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const templatePath = app.isPackaged
|
|
36
|
+
? path.join(projectRoot, 'dist/renderer/share-card-template/card.html')
|
|
37
|
+
: path.join(process.cwd(), 'src/renderer/share-card-template/card.html');
|
|
38
|
+
|
|
39
|
+
offscreenWin = new BrowserWindow({
|
|
40
|
+
width: 600,
|
|
41
|
+
height: 400,
|
|
42
|
+
show: false,
|
|
43
|
+
webPreferences: {
|
|
44
|
+
offscreen: true,
|
|
45
|
+
contextIsolation: false,
|
|
46
|
+
nodeIntegration: false,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
offscreenWin.loadFile(templatePath);
|
|
51
|
+
offscreenWin.on('closed', () => { offscreenWin = null; });
|
|
52
|
+
|
|
53
|
+
return offscreenWin;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function generateShareCard(quoteId?: string): Promise<boolean> {
|
|
57
|
+
if (isGenerating) return false;
|
|
58
|
+
isGenerating = true;
|
|
59
|
+
|
|
60
|
+
const locale = getStore().get('locale', DEFAULT_LOCALE) as Locale;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
if (!currentPetState) {
|
|
64
|
+
new Notification({ title: 'Claude Pet', body: t(locale, 'share_no_data') }).show();
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const win = getOffscreenWindow();
|
|
69
|
+
await new Promise<void>((resolve) => {
|
|
70
|
+
if (win.webContents.isLoading()) {
|
|
71
|
+
win.webContents.once('did-finish-load', () => resolve());
|
|
72
|
+
} else {
|
|
73
|
+
resolve();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const charImagePath = app.isPackaged
|
|
78
|
+
? path.join(projectRoot, 'dist/renderer/assets/claude-pet.png')
|
|
79
|
+
: path.join(process.cwd(), 'src/renderer/assets/claude-pet.png');
|
|
80
|
+
|
|
81
|
+
let characterDataUrl = '';
|
|
82
|
+
try {
|
|
83
|
+
const buf = fs.readFileSync(charImagePath);
|
|
84
|
+
characterDataUrl = `data:image/png;base64,${buf.toString('base64')}`;
|
|
85
|
+
} catch { /* fallback: no image */ }
|
|
86
|
+
|
|
87
|
+
let rarityBadge = '';
|
|
88
|
+
if (quoteId) {
|
|
89
|
+
const entry = getQuoteById(quoteId);
|
|
90
|
+
if (entry && entry.rarity !== 'common') {
|
|
91
|
+
const rarityKey = `rarity_${entry.rarity}` as Parameters<typeof t>[1];
|
|
92
|
+
rarityBadge = t(locale, rarityKey);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Get locale-specific mood name
|
|
97
|
+
const moodKey = `mood_${currentPetState.mood}` as Parameters<typeof t>[1];
|
|
98
|
+
const moodName = t(locale, moodKey);
|
|
99
|
+
|
|
100
|
+
// Format generation time in UTC (e.g. "2026-03-10 12:34 UTC")
|
|
101
|
+
const now = new Date();
|
|
102
|
+
const generatedAt = now.getUTCFullYear()
|
|
103
|
+
+ '-' + String(now.getUTCMonth() + 1).padStart(2, '0')
|
|
104
|
+
+ '-' + String(now.getUTCDate()).padStart(2, '0')
|
|
105
|
+
+ ' ' + String(now.getUTCHours()).padStart(2, '0')
|
|
106
|
+
+ ':' + String(now.getUTCMinutes()).padStart(2, '0')
|
|
107
|
+
+ ' UTC';
|
|
108
|
+
|
|
109
|
+
const cardData = {
|
|
110
|
+
mood: currentPetState.mood,
|
|
111
|
+
moodName,
|
|
112
|
+
moodColor: MOOD_COLORS[currentPetState.mood] || '#ec4899',
|
|
113
|
+
message: currentPetState.message,
|
|
114
|
+
weeklyPercent: currentPetState.utilizationPercent,
|
|
115
|
+
fiveHourPercent: currentPetState.fiveHourPercent,
|
|
116
|
+
resetsAt: currentPetState.resetsAt,
|
|
117
|
+
fiveHourResetsAt: currentPetState.fiveHourResetsAt,
|
|
118
|
+
characterDataUrl,
|
|
119
|
+
rarityBadge,
|
|
120
|
+
generatedAt,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
await win.webContents.executeJavaScript(`window.populateCard(${JSON.stringify(cardData)})`);
|
|
124
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
125
|
+
|
|
126
|
+
let image = await win.webContents.capturePage({ x: 0, y: 0, width: 600, height: 400 });
|
|
127
|
+
|
|
128
|
+
if (image.isEmpty()) {
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
130
|
+
image = await win.webContents.capturePage({ x: 0, y: 0, width: 600, height: 400 });
|
|
131
|
+
if (image.isEmpty()) return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Show save dialog instead of clipboard copy
|
|
135
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
136
|
+
const { filePath, canceled } = await dialog.showSaveDialog({
|
|
137
|
+
title: t(locale, 'tray_share'),
|
|
138
|
+
defaultPath: path.join(app.getPath('desktop'), `claude-pet-${timestamp}.png`),
|
|
139
|
+
filters: [{ name: 'PNG Image', extensions: ['png'] }],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (canceled || !filePath) return false;
|
|
143
|
+
|
|
144
|
+
fs.writeFileSync(filePath, image.toPNG());
|
|
145
|
+
|
|
146
|
+
return true;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error('[share-card] Error generating card:', err);
|
|
149
|
+
return false;
|
|
150
|
+
} finally {
|
|
151
|
+
isGenerating = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { app, dialog, nativeImage } from 'electron';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { SkinConfig, SkinUploadResponse } from '../shared/types';
|
|
5
|
+
import { getStore } from './ipc-handlers';
|
|
6
|
+
|
|
7
|
+
const SKINS_DIR = path.join(app.getPath('userData'), 'skins');
|
|
8
|
+
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
|
9
|
+
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif'];
|
|
10
|
+
|
|
11
|
+
function ensureSkinsDir(): void {
|
|
12
|
+
if (!fs.existsSync(SKINS_DIR)) {
|
|
13
|
+
fs.mkdirSync(SKINS_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cleanupOldSkins(keepPaths: string[]): void {
|
|
18
|
+
ensureSkinsDir();
|
|
19
|
+
const files = fs.readdirSync(SKINS_DIR);
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
const fullPath = path.join(SKINS_DIR, file);
|
|
22
|
+
if (!keepPaths.includes(fullPath)) {
|
|
23
|
+
try { fs.unlinkSync(fullPath); } catch { /* ignore */ }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const VALID_MOODS = ['happy', 'playful', 'sleepy', 'worried', 'bored', 'confused', 'sleeping'];
|
|
29
|
+
|
|
30
|
+
export async function uploadSkinImage(mood?: string): Promise<SkinUploadResponse> {
|
|
31
|
+
if (mood && !VALID_MOODS.includes(mood)) return { ok: false, error: 'invalid_format' };
|
|
32
|
+
|
|
33
|
+
const result = await dialog.showOpenDialog({
|
|
34
|
+
title: 'Select Character Image',
|
|
35
|
+
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif'] }],
|
|
36
|
+
properties: ['openFile'],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (result.canceled || result.filePaths.length === 0) return null;
|
|
40
|
+
|
|
41
|
+
const srcPath = result.filePaths[0];
|
|
42
|
+
const ext = path.extname(srcPath).toLowerCase();
|
|
43
|
+
|
|
44
|
+
if (!ALLOWED_EXTENSIONS.includes(ext)) return { ok: false, error: 'invalid_format' };
|
|
45
|
+
|
|
46
|
+
const stat = fs.statSync(srcPath);
|
|
47
|
+
if (stat.size > MAX_FILE_SIZE) return { ok: false, error: 'file_too_large' };
|
|
48
|
+
|
|
49
|
+
ensureSkinsDir();
|
|
50
|
+
const fileName = mood
|
|
51
|
+
? `skin-${mood}-${Date.now()}${ext}`
|
|
52
|
+
: `skin-${Date.now()}${ext}`;
|
|
53
|
+
const destPath = path.join(SKINS_DIR, fileName);
|
|
54
|
+
fs.copyFileSync(srcPath, destPath);
|
|
55
|
+
|
|
56
|
+
const image = nativeImage.createFromPath(destPath);
|
|
57
|
+
const size = image.getSize();
|
|
58
|
+
|
|
59
|
+
if (size.width === 0 || size.height === 0) {
|
|
60
|
+
try { fs.unlinkSync(destPath); } catch { /* ignore */ }
|
|
61
|
+
return { ok: false, error: 'invalid_format' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { ok: true, path: destPath, width: size.width, height: size.height };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resetSkin(): void {
|
|
68
|
+
const store = getStore();
|
|
69
|
+
(store as any).set('skin', { mode: 'default' });
|
|
70
|
+
cleanupOldSkins([]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getSkinConfig(): SkinConfig {
|
|
74
|
+
const store = getStore();
|
|
75
|
+
const config = (store as any).get('skin', { mode: 'default' }) as SkinConfig;
|
|
76
|
+
|
|
77
|
+
// Normalize spritesheet config for backward compatibility
|
|
78
|
+
if (config.spritesheet) {
|
|
79
|
+
const ss = config.spritesheet;
|
|
80
|
+
if (!ss.imageWidth && ss.frameWidth && ss.columns) {
|
|
81
|
+
ss.imageWidth = ss.frameWidth * ss.columns;
|
|
82
|
+
}
|
|
83
|
+
if (!ss.imageHeight && ss.frameHeight && ss.rows) {
|
|
84
|
+
ss.imageHeight = ss.frameHeight * ss.rows;
|
|
85
|
+
}
|
|
86
|
+
// Migrate old { col, row } moodMap to { startFrame, endFrame, fps }
|
|
87
|
+
if (ss.moodMap) {
|
|
88
|
+
for (const [key, val] of Object.entries(ss.moodMap)) {
|
|
89
|
+
const v = val as any;
|
|
90
|
+
if (v && typeof v.col === 'number' && typeof v.startFrame !== 'number') {
|
|
91
|
+
(ss.moodMap as any)[key] = { startFrame: v.row * ss.columns + v.col, endFrame: v.row * ss.columns + v.col, fps: 8 };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return config;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function saveSkinConfig(config: SkinConfig): void {
|
|
101
|
+
const store = getStore();
|
|
102
|
+
|
|
103
|
+
// Validate spritesheet dimensions
|
|
104
|
+
if (config.spritesheet) {
|
|
105
|
+
config.spritesheet.columns = Math.max(1, Math.min(16, config.spritesheet.columns || 1));
|
|
106
|
+
config.spritesheet.rows = Math.max(1, Math.min(16, config.spritesheet.rows || 1));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Collect paths to keep
|
|
110
|
+
const keepPaths: string[] = [];
|
|
111
|
+
if (config.singleImagePath) keepPaths.push(config.singleImagePath);
|
|
112
|
+
if (config.moodImages) {
|
|
113
|
+
Object.values(config.moodImages).forEach((p) => { if (p) keepPaths.push(p); });
|
|
114
|
+
}
|
|
115
|
+
if (config.spritesheet?.imagePath) keepPaths.push(config.spritesheet.imagePath);
|
|
116
|
+
|
|
117
|
+
cleanupOldSkins(keepPaths);
|
|
118
|
+
(store as any).set('skin', config);
|
|
119
|
+
}
|
package/src/main/tray.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { Tray, Menu, nativeImage, BrowserWindow, app } from 'electron';
|
|
3
|
+
import { checkForUpdatesManual } from './auto-updater';
|
|
4
|
+
import { showSettingsWindow } from './settings-window';
|
|
5
|
+
import { generateShareCard } from './share-card';
|
|
6
|
+
import { getStore } from './ipc-handlers';
|
|
7
|
+
import { t, DEFAULT_LOCALE } from '../shared/i18n';
|
|
8
|
+
import { Locale, PetSettings } from '../shared/types';
|
|
9
|
+
|
|
10
|
+
let trayInstance: Tray | null = null;
|
|
11
|
+
|
|
12
|
+
export function createTray(mainWindow: BrowserWindow): Tray {
|
|
13
|
+
const icon = buildTrayIcon();
|
|
14
|
+
|
|
15
|
+
trayInstance = new Tray(icon);
|
|
16
|
+
trayInstance.setToolTip('Claude Pet');
|
|
17
|
+
|
|
18
|
+
updateContextMenu(mainWindow);
|
|
19
|
+
|
|
20
|
+
trayInstance.on('double-click', () => {
|
|
21
|
+
toggleWindowVisibility(mainWindow);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Re-build menu when window visibility changes so label stays in sync
|
|
25
|
+
mainWindow.on('show', () => updateContextMenu(mainWindow));
|
|
26
|
+
mainWindow.on('hide', () => updateContextMenu(mainWindow));
|
|
27
|
+
|
|
28
|
+
return trayInstance;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildTrayIcon(): Electron.NativeImage {
|
|
32
|
+
const iconName = 'tray-icon.png';
|
|
33
|
+
const iconPath = app.isPackaged
|
|
34
|
+
? path.join(process.resourcesPath, iconName)
|
|
35
|
+
: path.join(process.cwd(), 'build', iconName);
|
|
36
|
+
return nativeImage.createFromPath(iconPath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toggleWindowVisibility(win: BrowserWindow): void {
|
|
40
|
+
if (win.isVisible()) {
|
|
41
|
+
win.hide();
|
|
42
|
+
} else {
|
|
43
|
+
win.show();
|
|
44
|
+
win.focus();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function updateContextMenu(mainWindow: BrowserWindow): void {
|
|
49
|
+
if (!trayInstance) return;
|
|
50
|
+
|
|
51
|
+
const isVisible = mainWindow.isVisible();
|
|
52
|
+
const locale = getStore().get('locale', DEFAULT_LOCALE) as Locale;
|
|
53
|
+
|
|
54
|
+
const contextMenu = Menu.buildFromTemplate([
|
|
55
|
+
{
|
|
56
|
+
label: isVisible ? 'Hide Pet' : 'Show Pet',
|
|
57
|
+
click: () => toggleWindowVisibility(mainWindow),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
label: t(locale, 'always_on_top'),
|
|
61
|
+
type: 'checkbox',
|
|
62
|
+
checked: mainWindow.isAlwaysOnTop(),
|
|
63
|
+
click: (menuItem) => {
|
|
64
|
+
mainWindow.setAlwaysOnTop(menuItem.checked);
|
|
65
|
+
getStore().set('alwaysOnTop' as keyof PetSettings, menuItem.checked);
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{ type: 'separator' },
|
|
69
|
+
{
|
|
70
|
+
label: t(locale, 'tray_share'),
|
|
71
|
+
click: () => { void generateShareCard(); },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
label: 'Settings...',
|
|
75
|
+
click: () => showSettingsWindow(),
|
|
76
|
+
},
|
|
77
|
+
{ type: 'separator' },
|
|
78
|
+
{
|
|
79
|
+
label: `v${app.isPackaged ? app.getVersion() : require(path.join(process.cwd(), 'package.json')).version}`,
|
|
80
|
+
enabled: false,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
label: t(locale, 'tray_check_update'),
|
|
84
|
+
click: () => { void checkForUpdatesManual(); },
|
|
85
|
+
},
|
|
86
|
+
{ type: 'separator' },
|
|
87
|
+
{
|
|
88
|
+
label: 'Quit',
|
|
89
|
+
click: () => app.quit(),
|
|
90
|
+
},
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
trayInstance.setContextMenu(contextMenu);
|
|
94
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<!-- Parent: ../AGENTS.md -->
|
|
2
|
+
<!-- Generated: 2026-03-14 -->
|
|
3
|
+
|
|
4
|
+
# src/renderer/
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
The `renderer/` directory is the React 19 frontend for the Electron renderer process. It renders the transparent widget (character, speech bubble, usage bars) and the settings window as a single Vite-bundled SPA. Page routing is hash-based (`#settings` → Settings page, default → main widget). The renderer communicates with the main process exclusively through `window.electronAPI`, the context-bridge object exposed by `preload.ts`.
|
|
8
|
+
|
|
9
|
+
## Key Files
|
|
10
|
+
| File | Description |
|
|
11
|
+
|------|-------------|
|
|
12
|
+
| `main.tsx` | React entry point — mounts `<App />` into `#root` |
|
|
13
|
+
| `App.tsx` | Root component — reads `window.location.hash` to route between `<MainView>` (widget) and `<Settings>`; `MainView` owns drag logic, hit-test mouse switching, speech bubble lifecycle, and layout |
|
|
14
|
+
| `index.html` | HTML shell for the Vite build |
|
|
15
|
+
| `electron.d.ts` | TypeScript declaration for `window.electronAPI` — must be kept in sync with `preload.ts` |
|
|
16
|
+
|
|
17
|
+
## Subdirectories
|
|
18
|
+
| Directory | Purpose |
|
|
19
|
+
|-----------|---------|
|
|
20
|
+
| `components/` | Reusable UI components: Character, SpeechBubble, UsageIndicator |
|
|
21
|
+
| `hooks/` | Custom React hooks: usePetState, useWidgetMode |
|
|
22
|
+
| `pages/` | Full-page views: Settings, Collection |
|
|
23
|
+
| `styles/` | Global CSS animations |
|
|
24
|
+
| `assets/` | Static assets bundled by Vite (character sprite PNG) |
|
|
25
|
+
| `share-card-template/` | HTML template rendered off-screen for share card image generation |
|
|
26
|
+
|
|
27
|
+
## For AI Agents
|
|
28
|
+
|
|
29
|
+
### Working In This Directory
|
|
30
|
+
- The renderer runs in a **context-isolated** Electron renderer process. `window.electronAPI` is the only bridge to the main process. Never use `require` or direct Node/Electron imports here.
|
|
31
|
+
- Hash-based routing: `App.tsx` switches on `window.location.hash`. To add a new page, add a hash check and a new component in `pages/`.
|
|
32
|
+
- The widget window is **200×250 px**. Keep all layout within these bounds; avoid fixed pixel sizes that assume a larger viewport.
|
|
33
|
+
- Drag-to-move is implemented in `App.tsx` using `screen{X,Y}` + `window.electronAPI.moveWindow()`. The character's `ref` is used for hit-testing mouse position.
|
|
34
|
+
- The renderer has no access to the filesystem or Node APIs. Skin image paths are `file://` URIs received from the main process.
|
|
35
|
+
- All user-facing strings must go through `t(locale, key)` from `src/shared/i18n.ts` — no hardcoded English strings in JSX.
|
|
36
|
+
- In development mode, `usePetState` exposes `window.setMood(moodName)` and `window.resetMood()` helpers in the browser console.
|
|
37
|
+
|
|
38
|
+
### Testing Requirements
|
|
39
|
+
- No automated tests exist for the renderer. Test visually via `npm run dev`.
|
|
40
|
+
- Verify all moods display correctly: angry, worried, happy, proud, confused, sleeping.
|
|
41
|
+
- Verify both mini and expanded usage bar modes (click the bar to toggle).
|
|
42
|
+
- Verify the speech bubble triggers on message change and auto-dismisses.
|
|
43
|
+
|
|
44
|
+
### Common Patterns
|
|
45
|
+
- Receiving live state: `window.electronAPI.onPetStateUpdate(callback)` returns an unsubscribe function; always return it from `useEffect`.
|
|
46
|
+
- One-time state fetch: `window.electronAPI.getPetState()`, `window.electronAPI.getSettings()`, `window.electronAPI.getSkinConfig()`.
|
|
47
|
+
- Inline styles with `CSSProperties` typed objects rather than CSS modules (see `pages/Settings.tsx` for the `s` style object pattern).
|
|
48
|
+
- Conditional animation: pass the current `expression` string to look up from `MOOD_ANIMATIONS` or `MOOD_AURA` record maps in `Character.tsx`.
|
|
49
|
+
|
|
50
|
+
## Dependencies
|
|
51
|
+
### Internal
|
|
52
|
+
- `../shared/types` — all shared types and `IPC_CHANNELS`
|
|
53
|
+
- `../shared/i18n` — `t()`, `DEFAULT_LOCALE`, `LOCALE_LABELS`
|
|
54
|
+
- `../core/pet-messages` — `MESSAGE_POOLS` (used by `usePetState` debug helper)
|
|
55
|
+
- `../core/quote-registry` — `QUOTE_REGISTRY` (rendered in Collection page)
|
|
56
|
+
- `../core/badge-registry` — `BADGE_REGISTRY` (rendered in Collection page)
|
|
57
|
+
|
|
58
|
+
### External
|
|
59
|
+
- `react` ^19, `react-dom` ^19
|
|
60
|
+
- `vite` ^7 (build), `@vitejs/plugin-react` (JSX transform)
|
|
61
|
+
|
|
62
|
+
<!-- MANUAL: -->
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { usePetState } from './hooks/usePetState';
|
|
3
|
+
import { useWidgetMode } from './hooks/useWidgetMode';
|
|
4
|
+
import { Character } from './components/Character';
|
|
5
|
+
import { SpeechBubble } from './components/SpeechBubble';
|
|
6
|
+
import { WeeklyBar, FiveHourBar, MiniBar, OfflineLabel } from './components/UsageIndicator';
|
|
7
|
+
import Settings from './pages/Settings';
|
|
8
|
+
import { Locale, SkinConfig } from '../shared/types';
|
|
9
|
+
import { t, DEFAULT_LOCALE } from '../shared/i18n';
|
|
10
|
+
|
|
11
|
+
function MainView() {
|
|
12
|
+
const petState = usePetState();
|
|
13
|
+
const { mode, onToggle } = useWidgetMode();
|
|
14
|
+
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
|
|
15
|
+
const [skinConfig, setSkinConfig] = useState<SkinConfig | undefined>();
|
|
16
|
+
const [showDragHint, setShowDragHint] = useState(false);
|
|
17
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
18
|
+
const [bubbleVisible, setBubbleVisible] = useState(false);
|
|
19
|
+
const [bubbleDirection, setBubbleDirection] = useState<'up' | 'down'>('up');
|
|
20
|
+
const prevMessageRef = useRef<string>('');
|
|
21
|
+
const characterRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const barsRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const lastIgnoreRef = useRef(true);
|
|
24
|
+
const bubbleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
window.electronAPI.getSettings().then((s) => {
|
|
28
|
+
if (s && (s as { locale?: Locale }).locale) {
|
|
29
|
+
setLocale((s as { locale: Locale }).locale);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
window.electronAPI.getSkinConfig().then((c) => {
|
|
36
|
+
if (c) setSkinConfig(c as SkinConfig);
|
|
37
|
+
});
|
|
38
|
+
return window.electronAPI.onSkinConfigUpdated((c) => {
|
|
39
|
+
if (c) setSkinConfig(c as SkinConfig);
|
|
40
|
+
});
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
// First-run drag hint
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const hintShown = localStorage.getItem('firstRunHintShown');
|
|
46
|
+
if (!hintShown) {
|
|
47
|
+
setShowDragHint(true);
|
|
48
|
+
localStorage.setItem('firstRunHintShown', 'true');
|
|
49
|
+
setTimeout(() => setShowDragHint(false), 3000);
|
|
50
|
+
}
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
// Hit-test based dynamic pointer event switching
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const isPointInRef = (ref: React.RefObject<HTMLDivElement | null>, x: number, y: number) => {
|
|
56
|
+
if (!ref.current) return false;
|
|
57
|
+
const rect = ref.current.getBoundingClientRect();
|
|
58
|
+
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
62
|
+
if (characterRef.current) {
|
|
63
|
+
const isOverInteractive = isPointInRef(characterRef, e.clientX, e.clientY)
|
|
64
|
+
|| isPointInRef(barsRef, e.clientX, e.clientY);
|
|
65
|
+
|
|
66
|
+
const shouldIgnore = !isOverInteractive;
|
|
67
|
+
if (shouldIgnore !== lastIgnoreRef.current) {
|
|
68
|
+
lastIgnoreRef.current = shouldIgnore;
|
|
69
|
+
window.electronAPI.setIgnoreMouse(shouldIgnore);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
75
|
+
return () => document.removeEventListener('mousemove', handleMouseMove);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
// Manual window drag on character area
|
|
79
|
+
const dragRef = useRef<{ startScreenX: number; startScreenY: number; winX: number; winY: number } | null>(null);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const onMouseDown = (e: MouseEvent) => {
|
|
82
|
+
// Only start drag if mouse is over the character hit area
|
|
83
|
+
if (!characterRef.current) return;
|
|
84
|
+
const rect = characterRef.current.getBoundingClientRect();
|
|
85
|
+
const isOverCharacter =
|
|
86
|
+
e.clientX >= rect.left && e.clientX <= rect.right &&
|
|
87
|
+
e.clientY >= rect.top && e.clientY <= rect.bottom;
|
|
88
|
+
if (!isOverCharacter) return;
|
|
89
|
+
|
|
90
|
+
dragRef.current = {
|
|
91
|
+
startScreenX: e.screenX,
|
|
92
|
+
startScreenY: e.screenY,
|
|
93
|
+
winX: window.screenX,
|
|
94
|
+
winY: window.screenY,
|
|
95
|
+
};
|
|
96
|
+
// Keep mouse events active during drag (prevent hit-test from disabling)
|
|
97
|
+
lastIgnoreRef.current = false;
|
|
98
|
+
window.electronAPI.setIgnoreMouse(false);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const onMouseMove = (e: MouseEvent) => {
|
|
102
|
+
if (!dragRef.current) return;
|
|
103
|
+
const dx = e.screenX - dragRef.current.startScreenX;
|
|
104
|
+
const dy = e.screenY - dragRef.current.startScreenY;
|
|
105
|
+
if (!isDragging && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
|
106
|
+
setIsDragging(true);
|
|
107
|
+
}
|
|
108
|
+
if (isDragging || Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
|
109
|
+
window.electronAPI.moveWindow(dragRef.current.winX + dx, dragRef.current.winY + dy);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const onMouseUp = () => {
|
|
114
|
+
if (dragRef.current) {
|
|
115
|
+
if (isDragging) {
|
|
116
|
+
window.electronAPI.savePosition(window.screenX, window.screenY);
|
|
117
|
+
}
|
|
118
|
+
dragRef.current = null;
|
|
119
|
+
setIsDragging(false);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
document.addEventListener('mousedown', onMouseDown);
|
|
124
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
125
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
126
|
+
return () => {
|
|
127
|
+
document.removeEventListener('mousedown', onMouseDown);
|
|
128
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
129
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
130
|
+
};
|
|
131
|
+
}, [isDragging]);
|
|
132
|
+
|
|
133
|
+
const mood = petState?.mood ?? 'sleeping';
|
|
134
|
+
const message = petState?.message ?? t(locale, 'loading_message');
|
|
135
|
+
const utilizationPercent = petState?.utilizationPercent ?? 0;
|
|
136
|
+
const fiveHourPercent = petState?.fiveHourPercent ?? null;
|
|
137
|
+
const fiveHourResetsAt = petState?.fiveHourResetsAt ?? null;
|
|
138
|
+
const dataSource = petState?.dataSource ?? 'none';
|
|
139
|
+
const rateLimited = petState?.rateLimited ?? false;
|
|
140
|
+
|
|
141
|
+
// Show speech bubble on message rotation (independent of bar state)
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (message !== prevMessageRef.current) {
|
|
144
|
+
prevMessageRef.current = message;
|
|
145
|
+
if (petState) {
|
|
146
|
+
const dir = window.screenY < 120 ? 'down' : 'up';
|
|
147
|
+
setBubbleDirection(dir);
|
|
148
|
+
setBubbleVisible(true);
|
|
149
|
+
if (bubbleTimerRef.current) clearTimeout(bubbleTimerRef.current);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}, [message, petState]);
|
|
153
|
+
|
|
154
|
+
const isExpanded = mode === 'expanded';
|
|
155
|
+
|
|
156
|
+
// Right-click context menu
|
|
157
|
+
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
window.electronAPI.showContextMenu();
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
const handleBubbleComplete = useCallback(() => {
|
|
163
|
+
bubbleTimerRef.current = setTimeout(() => {
|
|
164
|
+
setBubbleVisible(false);
|
|
165
|
+
bubbleTimerRef.current = null;
|
|
166
|
+
}, 1000);
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
const bubble = bubbleVisible ? (
|
|
170
|
+
<SpeechBubble message={message} mood={mood} tailDirection={bubbleDirection === 'up' ? 'down' : 'up'} onComplete={handleBubbleComplete} />
|
|
171
|
+
) : null;
|
|
172
|
+
|
|
173
|
+
const character = (
|
|
174
|
+
<div
|
|
175
|
+
onContextMenu={handleContextMenu}
|
|
176
|
+
style={{ position: 'relative' }}
|
|
177
|
+
>
|
|
178
|
+
<Character
|
|
179
|
+
ref={characterRef}
|
|
180
|
+
expression={mood}
|
|
181
|
+
growthStage={petState?.growthStage}
|
|
182
|
+
isDragging={isDragging}
|
|
183
|
+
skinConfig={skinConfig}
|
|
184
|
+
/>
|
|
185
|
+
{showDragHint && (
|
|
186
|
+
<div style={{
|
|
187
|
+
position: 'absolute',
|
|
188
|
+
bottom: -20,
|
|
189
|
+
left: '50%',
|
|
190
|
+
transform: 'translateX(-50%)',
|
|
191
|
+
background: 'rgba(0,0,0,0.8)',
|
|
192
|
+
color: '#fff',
|
|
193
|
+
fontSize: 10,
|
|
194
|
+
padding: '2px 8px',
|
|
195
|
+
borderRadius: 4,
|
|
196
|
+
whiteSpace: 'nowrap',
|
|
197
|
+
animation: 'fade-out 3s ease forwards',
|
|
198
|
+
}}>
|
|
199
|
+
Drag me to move!
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const isStale = false;
|
|
206
|
+
const usageBars = dataSource === 'none' ? (
|
|
207
|
+
<OfflineLabel locale={locale} rateLimited={rateLimited} />
|
|
208
|
+
) : isExpanded ? (
|
|
209
|
+
<div ref={barsRef} onClick={onToggle} style={{ display: 'flex', alignItems: 'flex-start', gap: 6, marginTop: 6, cursor: 'pointer' }}>
|
|
210
|
+
<WeeklyBar utilizationPercent={utilizationPercent} resetsAt={petState?.resetsAt ?? null} mood={mood} stale={isStale} />
|
|
211
|
+
{fiveHourPercent != null && (
|
|
212
|
+
<FiveHourBar fiveHourPercent={fiveHourPercent} fiveHourResetsAt={fiveHourResetsAt} stale={isStale} />
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
) : (
|
|
216
|
+
<div ref={barsRef} onClick={onToggle} style={{ cursor: 'pointer' }}>
|
|
217
|
+
<MiniBar utilizationPercent={utilizationPercent} mood={mood} stale={isStale} resetsAt={petState?.resetsAt} />
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div style={{
|
|
223
|
+
width: '100%',
|
|
224
|
+
height: '100%',
|
|
225
|
+
display: 'flex',
|
|
226
|
+
flexDirection: 'column',
|
|
227
|
+
alignItems: 'center',
|
|
228
|
+
justifyContent: bubbleDirection === 'up' ? 'flex-end' : 'flex-start',
|
|
229
|
+
background: 'transparent',
|
|
230
|
+
padding: '8px 0',
|
|
231
|
+
transition: 'all 300ms ease',
|
|
232
|
+
}}>
|
|
233
|
+
{bubbleDirection === 'up' ? (
|
|
234
|
+
<>
|
|
235
|
+
{bubble}
|
|
236
|
+
{bubble && <div style={{ height: 6 }} />}
|
|
237
|
+
{character}
|
|
238
|
+
<div style={{ marginTop: 4 }}>{usageBars}</div>
|
|
239
|
+
</>
|
|
240
|
+
) : (
|
|
241
|
+
<>
|
|
242
|
+
{character}
|
|
243
|
+
<div style={{ marginTop: 4 }}>{usageBars}</div>
|
|
244
|
+
{bubble && <div style={{ height: 6 }} />}
|
|
245
|
+
{bubble}
|
|
246
|
+
</>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function getHash(): string {
|
|
253
|
+
return window.location.hash.replace(/^#\/?/, '');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default function App() {
|
|
257
|
+
const [hash, setHash] = useState(getHash);
|
|
258
|
+
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
const handleHashChange = () => setHash(getHash());
|
|
261
|
+
window.addEventListener('hashchange', handleHashChange);
|
|
262
|
+
return () => window.removeEventListener('hashchange', handleHashChange);
|
|
263
|
+
}, []);
|
|
264
|
+
|
|
265
|
+
if (hash === 'settings') {
|
|
266
|
+
return <Settings />;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return <MainView />;
|
|
270
|
+
}
|