diff --git a/.gitignore b/.gitignore index 107eddc10..94f95cfdc 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ packages/cli/src/config/system-prompt.precompiled.ts .python-version 01-landing.png .playwright-cli/ +.superpowers/ +packages/desktop/bun.lock diff --git a/bun.lock b/bun.lock index 45574e7c0..294503449 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ }, "packages/cli": { "name": "@pizzapi/cli", - "version": "0.5.0-dev.2", + "version": "0.5.0-dev.4", "bin": { "pizza": "src/index.ts", "pizzapi": "src/index.ts", @@ -1326,7 +1326,7 @@ "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], - "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], @@ -3238,7 +3238,7 @@ "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], diff --git a/docs/specs/2026-04-01-electron-desktop-client-design.md b/docs/specs/2026-04-01-electron-desktop-client-design.md new file mode 100644 index 000000000..31189ccf5 --- /dev/null +++ b/docs/specs/2026-04-01-electron-desktop-client-design.md @@ -0,0 +1,274 @@ +# Electron Desktop Client — Design Spec + +**Date:** 2026-04-01 +**Status:** Draft +**Package:** `packages/desktop` + +--- + +## Overview + +A native macOS desktop application that wraps PizzaPi into a self-contained experience. Users launch the app and get a fully working PizzaPi environment — relay server, runner daemon, and web UI — without touching a terminal. + +The Electron main process orchestrates child processes (relay server, runner daemon) and provides native OS integration (system tray, notifications, auto-launch). The renderer loads the existing `@pizzapi/ui` web app in a BrowserWindow, unchanged. + +## Goals + +- **Self-contained**: launch the app, everything starts automatically +- **Zero UI duplication**: renderer IS the existing web UI +- **Native feel**: system tray, OS notifications, login item +- **macOS first**: target macOS (arm64) for v1, expand later + +## Non-Goals (v1) + +- Windows or Linux support +- Bundled Redis (user must have Redis installed) +- Custom desktop-specific UI modifications +- Auto-updates or code signing +- Global hotkeys or deep links (pizzapi:// protocol) + +--- + +## Architecture + +### Process Model + +Four processes at runtime: + +| Process | Role | Implementation | +|---------|------|----------------| +| **Main** | App lifecycle, window management, tray, IPC | Electron main process (Node.js) | +| **Renderer** | Web UI | BrowserWindow loading `@pizzapi/ui` | +| **Relay Server** | HTTP + WebSocket relay, auth, sessions | `child_process.fork()` running `@pizzapi/server` | +| **Runner Daemon** | Agent execution | `child_process.spawn()` running `pizzapi runner` | + +### Startup Sequence + +1. `app.whenReady()` fires +2. Check Redis connectivity (fail with dialog if unavailable) +3. Spawn relay server on `localhost:3001` (or next available port) +4. Health-check the server (poll `/api/health` until 200) +5. Spawn runner daemon, connecting to the local relay +6. Create BrowserWindow, load UI pointing at `localhost:3001` +7. Initialize system tray with status indicators +8. Register IPC handlers + +### Shutdown Sequence + +1. User clicks Quit (or Cmd+Q) +2. Send SIGTERM to runner daemon, wait up to 5s +3. Send SIGTERM to relay server, wait up to 5s +4. Force-kill any remaining child processes +5. `app.quit()` + +### Window Behavior + +- Closing the window hides it (app stays in tray), doesn't quit +- Cmd+Q or tray "Quit" actually exits +- Window state (size, position) persisted via `electron-window-state` or manual `localStorage` + +--- + +## Package Structure + +``` +packages/desktop/ +├── package.json +├── electron-builder.yml +├── tsconfig.json +└── src/ + ├── main/ + │ ├── index.ts ← app entry, window creation + │ ├── server-manager.ts ← spawn/stop relay server + │ ├── runner-manager.ts ← spawn/stop runner daemon + │ ├── tray.ts ← system tray icon + menu + │ ├── notifications.ts ← native OS notifications + │ ├── auto-launch.ts ← login item registration + │ └── ipc.ts ← IPC handlers (main↔renderer) + └── preload/ + └── index.ts ← contextBridge exposing safe APIs +``` + +--- + +## Native OS Features + +### System Tray + +- **Tray icon**: Pizza emoji or custom icon, color-coded by status: + - Green: all services healthy + - Yellow: starting or degraded + - Red: error (server crashed, Redis down) +- **Click**: toggles window visibility +- **Context menu**: + - Show Window + - New Session + - ─── (separator) + - Server: localhost:3001 ✓ (status indicator) + - Runner: Connected ✓ + - Redis: Connected ✓ + - ─── (separator) + - Preferences… + - Quit PizzaPi + +### Native Notifications + +Delivered via Electron's `Notification` API. Three notification types: + +| Event | Title | Body | Click Action | +|-------|-------|------|-------------| +| Session complete | "Session Complete" | Agent finished task "{name}" in {duration} | Focus window, navigate to session | +| Agent needs input | "Agent Needs Input" | Session "{name}" is waiting for your response | Focus window, navigate to session | +| Service error | "Service Error" | {error description} | Focus window | + +Notifications are triggered by listening to the relay server's Socket.IO events from the main process. + +### Auto-Launch + +- Uses `app.setLoginItemSettings({ openAtLogin: true, openAsHidden: true })` on macOS +- Launches minimized to tray (no window shown) +- Toggled via a setting in tray Preferences or a future settings page +- Persisted in Electron's `app.getPath('userData')` config + +--- + +## Dev Workflow + +### Development Mode + +```bash +bun run dev:desktop +``` + +Uses `concurrently` to run: +1. Vite dev server (`packages/ui`) → `localhost:5173` +2. Relay server (`packages/server`) → `localhost:3001` +3. Electron main process with `--dev` flag + +In dev mode: +- BrowserWindow loads `http://localhost:5173` (Vite HMR) +- Vite proxies `/api` and `/socket.io` to `localhost:3001` +- Main process TypeScript compiled on-the-fly by Electron (via `tsx` or `electron-vite`) + +### Production Build + +```bash +bun run build:desktop +``` + +Steps: +1. Build `packages/ui` → `dist/` (static assets) +2. Build `packages/server` → `dist/` (compiled server) +3. Build `packages/cli` → `dist/` (runner daemon) +4. Compile `packages/desktop/src/main` → JS +5. `electron-builder` packages everything into `PizzaPi.app` + +In production mode: +- BrowserWindow loads UI assets from bundled `packages/ui/dist` +- Main process spawns server from bundled `packages/server/dist` +- Runner uses bundled `packages/cli/dist` + +### New Root Scripts + +```json +{ + "dev:desktop": "cd packages/desktop && bun run dev", + "build:desktop": "bun run build:ui && bun run build:server && bun run build:cli && cd packages/desktop && bun run build", + "package:desktop": "cd packages/desktop && bun run package" +} +``` + +--- + +## Dependencies + +### Runtime +- `electron` — app runtime + +### Dev / Build +- `electron-builder` — packaging into `.app` / `.dmg` +- `electron-log` — structured logging for main process + +### Workspace Dependencies +- `@pizzapi/ui` — renderer content (built assets) +- `@pizzapi/server` — relay server (spawned as child process) +- `@pizzapi/cli` — runner daemon (spawned as child process) +- `@pizzapi/protocol` — shared types for Socket.IO events + +--- + +## IPC Contract + +The preload script exposes a minimal API via `contextBridge`: + +```typescript +interface DesktopAPI { + // App info + getVersion(): string; + getPlatform(): string; + + // Service status + onServiceStatus(callback: (status: ServiceStatus) => void): void; + + // Window controls + minimizeToTray(): void; + + // Settings + getAutoLaunch(): Promise; + setAutoLaunch(enabled: boolean): Promise; +} + +interface ServiceStatus { + server: 'starting' | 'running' | 'error' | 'stopped'; + runner: 'starting' | 'running' | 'error' | 'stopped'; + redis: 'connected' | 'disconnected'; +} +``` + +The renderer doesn't need to call most of these directly — the existing UI already connects to the server via Socket.IO. The IPC layer is primarily for desktop-specific features (tray status, auto-launch toggle). + +--- + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| Redis not available | Show dialog: "Redis is required. Please install and start Redis." with link to install instructions. Don't start server. | +| Server crashes | Tray goes red. Notification: "Server crashed — restarting…". Auto-restart up to 3 times, then show error dialog. | +| Runner crashes | Tray shows degraded. Notification: "Runner disconnected — restarting…". Auto-restart up to 3 times. | +| Port 3001 in use | Try next available port (3002, 3003…). Pass port to UI via query param or env. | +| Electron crash | Standard Electron crash reporter. Log to `~/Library/Logs/PizzaPi/`. | + +--- + +## File Locations (macOS) + +| Purpose | Path | +|---------|------| +| App data | `~/Library/Application Support/PizzaPi/` | +| Logs | `~/Library/Logs/PizzaPi/` | +| Config | `~/.pizzapi/config.json` (shared with CLI) | +| Database | `~/Library/Application Support/PizzaPi/auth.db` | + +--- + +## Testing Strategy + +- **Unit tests**: server-manager, runner-manager lifecycle logic (spawn, health-check, restart, shutdown) +- **Integration tests**: full startup/shutdown sequence with mocked child processes +- **Manual testing**: tray behavior, notifications, auto-launch, window state persistence + +Test files co-located: `server-manager.test.ts`, `runner-manager.test.ts`, etc. + +--- + +## Future Considerations (Not in v1) + +- Windows and Linux support +- Bundled Redis (embed redis-server binary) +- Auto-updates via `electron-updater` +- Code signing and notarization for macOS distribution +- `.dmg` installer and Homebrew cask +- Global hotkeys (toggle visibility, new session) +- Deep links (`pizzapi://` protocol handler) +- Custom titlebar with traffic-light integration diff --git a/docs/specs/2026-04-01-electron-desktop-client-plan.md b/docs/specs/2026-04-01-electron-desktop-client-plan.md new file mode 100644 index 000000000..c3899f739 --- /dev/null +++ b/docs/specs/2026-04-01-electron-desktop-client-plan.md @@ -0,0 +1,1261 @@ +# Electron Desktop Client — 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:** Create a native macOS Electron desktop app that embeds the PizzaPi relay server, runner daemon, and web UI into a self-contained application with system tray, native notifications, and auto-launch. + +**Architecture:** New `packages/desktop` workspace. Electron main process spawns the relay server and runner daemon as child processes. The renderer loads the existing `@pizzapi/ui` in a BrowserWindow — zero UI duplication. Native features (tray, notifications, auto-launch) live in the main process. + +**Tech Stack:** Electron 35+, electron-builder, TypeScript, `child_process` for server/runner lifecycle. + +--- + +## File Structure + +``` +packages/desktop/ +├── package.json ← workspace package, electron + electron-builder deps +├── electron-builder.yml ← electron-builder config (macOS arm64) +├── tsconfig.json ← extends root tsconfig.base.json +├── assets/ +│ ├── icon.png ← app icon (1024x1024) +│ ├── tray-default.png ← tray icon default (22x22 @2x template) +│ ├── tray-warning.png ← tray icon warning state +│ └── tray-error.png ← tray icon error state +├── src/ +│ ├── main/ +│ │ ├── index.ts ← app entry: ready, quit, window creation +│ │ ├── server-manager.ts ← spawn/stop/health-check relay server +│ │ ├── runner-manager.ts ← spawn/stop runner daemon +│ │ ├── tray.ts ← system tray icon + context menu +│ │ ├── notifications.ts ← native OS notification dispatch +│ │ ├── auto-launch.ts ← login item settings +│ │ ├── ipc.ts ← IPC channel handlers +│ │ ├── config.ts ← paths, ports, constants +│ │ └── logger.ts ← electron-log setup +│ └── preload/ +│ └── index.ts ← contextBridge API +└── tests/ + ├── server-manager.test.ts ← server lifecycle tests + └── runner-manager.test.ts ← runner lifecycle tests +``` + +--- + +### Task 1: Scaffold the `packages/desktop` workspace + +**Files:** +- Create: `packages/desktop/package.json` +- Create: `packages/desktop/tsconfig.json` +- Create: `packages/desktop/electron-builder.yml` +- Modify: root `package.json` (add workspace + scripts) + +- [ ] **Step 1: Create `packages/desktop/package.json`** + +```json +{ + "name": "@pizzapi/desktop", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/main/index.js", + "scripts": { + "dev": "concurrently \"bun run dev:electron\" \"bun run --cwd ../ui dev\" \"bun run --cwd ../server dev\" --kill-others-on-exit", + "dev:electron": "electron --inspect . --dev", + "build": "tsc --build", + "package": "electron-builder --mac", + "start": "electron ." + }, + "dependencies": { + "@pizzapi/protocol": "workspace:*", + "electron-log": "^5.3.0" + }, + "devDependencies": { + "electron": "^35.0.0", + "electron-builder": "^26.0.0", + "concurrently": "^9.2.1", + "typescript": "^5.7.0" + } +} +``` + +- [ ] **Step 2: Create `packages/desktop/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2022", + "declaration": true, + "sourceMap": true + }, + "include": ["src"], + "references": [ + { "path": "../protocol" } + ] +} +``` + +- [ ] **Step 3: Create `packages/desktop/electron-builder.yml`** + +```yaml +appId: com.pizzapi.desktop +productName: PizzaPi +copyright: Copyright © 2026 PizzaPi + +directories: + output: release + +mac: + category: public.app-category.developer-tools + target: + - target: dir + arch: + - arm64 + icon: assets/icon.png + +files: + - dist/**/* + - assets/**/* + - package.json + # Bundle the built UI assets + - from: ../ui/dist + to: ui-dist + filter: + - "**/*" + # Bundle the built server + - from: ../server/dist + to: server-dist + filter: + - "**/*" + # Bundle the built CLI (runner) + - from: ../cli/dist + to: cli-dist + filter: + - "**/*" + +extraMetadata: + main: dist/main/index.js +``` + +- [ ] **Step 4: Add workspace and scripts to root `package.json`** + +Add `"packages/desktop"` to the `workspaces` array. Add these scripts: + +```json +{ + "dev:desktop": "cd packages/desktop && bun run dev", + "build:desktop": "bun run build:ui && bun run build:server && bun run build:cli && cd packages/desktop && bun run build", + "package:desktop": "bun run build:desktop && cd packages/desktop && bun run package" +} +``` + +- [ ] **Step 5: Run `bun install` to link the new workspace** + +```bash +bun install +``` + +Expected: installs electron and electron-builder, links workspace deps. + +- [ ] **Step 6: Commit** + +```bash +git add packages/desktop/package.json packages/desktop/tsconfig.json packages/desktop/electron-builder.yml package.json bun.lock +git commit -m "feat(desktop): scaffold Electron workspace" +``` + +--- + +### Task 2: Config and logger modules + +**Files:** +- Create: `packages/desktop/src/main/config.ts` +- Create: `packages/desktop/src/main/logger.ts` + +- [ ] **Step 1: Create `packages/desktop/src/main/config.ts`** + +```typescript +import { app } from "electron"; +import { join } from "node:path"; + +/** Whether we're running in dev mode (passed via --dev flag). */ +export const isDev = process.argv.includes("--dev"); + +/** Default port for the relay server. */ +export const DEFAULT_SERVER_PORT = 3001; + +/** Vite dev server URL (used in dev mode only). */ +export const VITE_DEV_URL = "http://localhost:5173"; + +/** Path to the bundled UI dist assets (production). */ +export function getUIDistPath(): string { + if (isDev) { + return join(__dirname, "..", "..", "..", "ui", "dist"); + } + // In packaged app, electron-builder places them at ui-dist/ + return join(process.resourcesPath, "app", "ui-dist"); +} + +/** Path to the bundled server entry (production). */ +export function getServerEntryPath(): string { + if (isDev) { + return join(__dirname, "..", "..", "..", "server", "src", "index.ts"); + } + return join(process.resourcesPath, "app", "server-dist", "index.js"); +} + +/** Path to the bundled CLI entry for runner (production). */ +export function getRunnerEntryPath(): string { + if (isDev) { + return join(__dirname, "..", "..", "..", "cli", "src", "index.ts"); + } + return join(process.resourcesPath, "app", "cli-dist", "index.js"); +} + +/** App data directory. */ +export function getAppDataPath(): string { + return app.getPath("userData"); +} + +/** Logs directory. */ +export function getLogsPath(): string { + return app.getPath("logs"); +} + +/** Max restart attempts for child processes before showing error. */ +export const MAX_RESTART_ATTEMPTS = 3; + +/** Health check polling interval in ms. */ +export const HEALTH_CHECK_INTERVAL = 500; + +/** Health check timeout in ms. */ +export const HEALTH_CHECK_TIMEOUT = 30_000; +``` + +- [ ] **Step 2: Create `packages/desktop/src/main/logger.ts`** + +```typescript +import log from "electron-log/main"; + +log.initialize(); +log.transports.file.level = "info"; +log.transports.console.level = "debug"; + +export default log; +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/desktop/src/main/config.ts packages/desktop/src/main/logger.ts +git commit -m "feat(desktop): add config and logger modules" +``` + +--- + +### Task 3: Server manager — spawn, health-check, stop + +**Files:** +- Create: `packages/desktop/src/main/server-manager.ts` +- Create: `packages/desktop/tests/server-manager.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/desktop/tests/server-manager.test.ts +import { describe, test, expect, mock, beforeEach } from "bun:test"; + +// We test the pure logic by mocking child_process and fetch +const mockSpawn = mock(() => ({ + pid: 1234, + on: mock(() => {}), + kill: mock(() => true), + stdout: { on: mock(() => {}) }, + stderr: { on: mock(() => {}) }, +})); + +mock.module("node:child_process", () => ({ + spawn: mockSpawn, +})); + +describe("ServerManager", () => { + test("start() spawns a child process with the correct entry path", async () => { + const { ServerManager } = await import("../src/main/server-manager.js"); + const mgr = new ServerManager({ port: 3001, isDev: true }); + + // Mock fetch for health check + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => Promise.resolve(new Response("ok", { status: 200 }))) as any; + + await mgr.start(); + + expect(mockSpawn).toHaveBeenCalled(); + expect(mgr.isRunning()).toBe(true); + + globalThis.fetch = originalFetch; + }); + + test("stop() sends SIGTERM to the child process", async () => { + const { ServerManager } = await import("../src/main/server-manager.js"); + const mgr = new ServerManager({ port: 3001, isDev: true }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => Promise.resolve(new Response("ok", { status: 200 }))) as any; + + await mgr.start(); + mgr.stop(); + + expect(mgr.isRunning()).toBe(false); + + globalThis.fetch = originalFetch; + }); + + test("getPort() returns the configured port", () => { + const { ServerManager } = await import("../src/main/server-manager.js"); + const mgr = new ServerManager({ port: 3042, isDev: true }); + expect(mgr.getPort()).toBe(3042); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd packages/desktop && bun test tests/server-manager.test.ts +``` + +Expected: FAIL — module `../src/main/server-manager.js` not found. + +- [ ] **Step 3: Implement `server-manager.ts`** + +```typescript +// packages/desktop/src/main/server-manager.ts +import { spawn, type ChildProcess } from "node:child_process"; +import { + getServerEntryPath, + HEALTH_CHECK_INTERVAL, + HEALTH_CHECK_TIMEOUT, + MAX_RESTART_ATTEMPTS, +} from "./config.js"; +import log from "./logger.js"; + +export interface ServerManagerOptions { + port: number; + isDev: boolean; +} + +export class ServerManager { + private child: ChildProcess | null = null; + private port: number; + private isDev: boolean; + private restartCount = 0; + private stopping = false; + + constructor(opts: ServerManagerOptions) { + this.port = opts.port; + this.isDev = opts.isDev; + } + + /** Spawn the relay server and wait for it to become healthy. */ + async start(): Promise { + this.stopping = false; + const entry = getServerEntryPath(); + log.info(`Starting relay server on port ${this.port}...`); + + const env = { + ...process.env, + PORT: String(this.port), + NODE_ENV: this.isDev ? "development" : "production", + }; + + this.child = spawn("bun", ["run", entry], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + + this.child.stdout?.on("data", (data: Buffer) => { + log.info(`[server] ${data.toString().trim()}`); + }); + + this.child.stderr?.on("data", (data: Buffer) => { + log.warn(`[server] ${data.toString().trim()}`); + }); + + this.child.on("exit", (code, signal) => { + log.info(`Server exited: code=${code} signal=${signal}`); + this.child = null; + if (!this.stopping && this.restartCount < MAX_RESTART_ATTEMPTS) { + this.restartCount++; + log.warn(`Restarting server (attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`); + this.start().catch((err) => log.error("Server restart failed:", err)); + } + }); + + await this.waitForHealthy(); + this.restartCount = 0; + log.info(`Relay server healthy on port ${this.port}`); + } + + /** Poll /health until 200 or timeout. */ + private async waitForHealthy(): Promise { + const deadline = Date.now() + HEALTH_CHECK_TIMEOUT; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://localhost:${this.port}/health`); + if (res.ok) return; + } catch { + // Server not ready yet + } + await new Promise((r) => setTimeout(r, HEALTH_CHECK_INTERVAL)); + } + throw new Error(`Server failed to become healthy within ${HEALTH_CHECK_TIMEOUT}ms`); + } + + /** Gracefully stop the server. */ + stop(): void { + this.stopping = true; + if (this.child) { + log.info("Stopping relay server..."); + this.child.kill("SIGTERM"); + this.child = null; + } + } + + /** Force-kill if still running. */ + forceKill(): void { + this.stopping = true; + if (this.child) { + this.child.kill("SIGKILL"); + this.child = null; + } + } + + isRunning(): boolean { + return this.child !== null; + } + + getPort(): number { + return this.port; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd packages/desktop && bun test tests/server-manager.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/desktop/src/main/server-manager.ts packages/desktop/tests/server-manager.test.ts +git commit -m "feat(desktop): add server manager with health check and auto-restart" +``` + +--- + +### Task 4: Runner manager — spawn, stop + +**Files:** +- Create: `packages/desktop/src/main/runner-manager.ts` +- Create: `packages/desktop/tests/runner-manager.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/desktop/tests/runner-manager.test.ts +import { describe, test, expect, mock } from "bun:test"; + +const mockSpawn = mock(() => ({ + pid: 5678, + on: mock(() => {}), + kill: mock(() => true), + stdout: { on: mock(() => {}) }, + stderr: { on: mock(() => {}) }, +})); + +mock.module("node:child_process", () => ({ + spawn: mockSpawn, +})); + +describe("RunnerManager", () => { + test("start() spawns the runner daemon pointing at the local server", async () => { + const { RunnerManager } = await import("../src/main/runner-manager.js"); + const mgr = new RunnerManager({ serverPort: 3001, isDev: true }); + + mgr.start(); + + expect(mockSpawn).toHaveBeenCalled(); + expect(mgr.isRunning()).toBe(true); + }); + + test("stop() sends SIGTERM to runner", () => { + const { RunnerManager } = await import("../src/main/runner-manager.js"); + const mgr = new RunnerManager({ serverPort: 3001, isDev: true }); + + mgr.start(); + mgr.stop(); + + expect(mgr.isRunning()).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd packages/desktop && bun test tests/runner-manager.test.ts +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `runner-manager.ts`** + +```typescript +// packages/desktop/src/main/runner-manager.ts +import { spawn, type ChildProcess } from "node:child_process"; +import { getRunnerEntryPath, MAX_RESTART_ATTEMPTS } from "./config.js"; +import log from "./logger.js"; + +export interface RunnerManagerOptions { + serverPort: number; + isDev: boolean; +} + +export class RunnerManager { + private child: ChildProcess | null = null; + private serverPort: number; + private isDev: boolean; + private restartCount = 0; + private stopping = false; + + constructor(opts: RunnerManagerOptions) { + this.serverPort = opts.serverPort; + this.isDev = opts.isDev; + } + + /** Spawn the runner daemon. */ + start(): void { + this.stopping = false; + const entry = getRunnerEntryPath(); + log.info("Starting runner daemon..."); + + const env = { + ...process.env, + PIZZAPI_SERVER_URL: `http://localhost:${this.serverPort}`, + }; + + this.child = spawn("bun", ["run", entry, "runner"], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + + this.child.stdout?.on("data", (data: Buffer) => { + log.info(`[runner] ${data.toString().trim()}`); + }); + + this.child.stderr?.on("data", (data: Buffer) => { + log.warn(`[runner] ${data.toString().trim()}`); + }); + + this.child.on("exit", (code, signal) => { + log.info(`Runner exited: code=${code} signal=${signal}`); + this.child = null; + if (!this.stopping && this.restartCount < MAX_RESTART_ATTEMPTS) { + this.restartCount++; + log.warn(`Restarting runner (attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`); + this.start(); + } + }); + } + + /** Gracefully stop the runner. */ + stop(): void { + this.stopping = true; + if (this.child) { + log.info("Stopping runner daemon..."); + this.child.kill("SIGTERM"); + this.child = null; + } + } + + /** Force-kill if still running. */ + forceKill(): void { + this.stopping = true; + if (this.child) { + this.child.kill("SIGKILL"); + this.child = null; + } + } + + isRunning(): boolean { + return this.child !== null; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd packages/desktop && bun test tests/runner-manager.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/desktop/src/main/runner-manager.ts packages/desktop/tests/runner-manager.test.ts +git commit -m "feat(desktop): add runner manager with auto-restart" +``` + +--- + +### Task 5: System tray + +**Files:** +- Create: `packages/desktop/src/main/tray.ts` +- Create: `packages/desktop/assets/tray-default.png` (placeholder) +- Create: `packages/desktop/assets/tray-warning.png` (placeholder) +- Create: `packages/desktop/assets/tray-error.png` (placeholder) + +- [ ] **Step 1: Create placeholder tray icon assets** + +Create 44x44 PNG images (22pt @2x macOS template images). For now, use simple colored circles. The filenames must end in `Template.png` for macOS to treat them as template images (auto-adapts to dark/light menu bar). + +```bash +# Create assets directory +mkdir -p packages/desktop/assets +``` + +Generate minimal 44x44 placeholder PNGs (or copy from existing PizzaPi assets): + +```bash +# Use the existing pizza.svg as a base, or create placeholders +cp packages/ui/public/pwa-64x64.png packages/desktop/assets/tray-default.png +cp packages/ui/public/pwa-64x64.png packages/desktop/assets/tray-warning.png +cp packages/ui/public/pwa-64x64.png packages/desktop/assets/tray-error.png +cp packages/ui/public/pwa-512x512.png packages/desktop/assets/icon.png +``` + +- [ ] **Step 2: Implement `tray.ts`** + +```typescript +// packages/desktop/src/main/tray.ts +import { Tray, Menu, nativeImage, type BrowserWindow } from "electron"; +import { join } from "node:path"; +import log from "./logger.js"; + +export type ServiceHealth = "healthy" | "degraded" | "error"; + +export interface TrayStatus { + server: "starting" | "running" | "error" | "stopped"; + runner: "starting" | "running" | "error" | "stopped"; + redis: "connected" | "disconnected"; +} + +export class AppTray { + private tray: Tray; + private window: BrowserWindow; + private status: TrayStatus = { + server: "stopped", + runner: "stopped", + redis: "disconnected", + }; + + constructor(window: BrowserWindow) { + this.window = window; + + const iconPath = join(__dirname, "..", "..", "assets", "tray-default.png"); + const icon = nativeImage.createFromPath(iconPath).resize({ width: 22, height: 22 }); + icon.setTemplateImage(true); + + this.tray = new Tray(icon); + this.tray.setToolTip("PizzaPi"); + + this.tray.on("click", () => { + if (this.window.isVisible()) { + this.window.hide(); + } else { + this.window.show(); + this.window.focus(); + } + }); + + this.rebuildMenu(); + } + + updateStatus(status: Partial): void { + Object.assign(this.status, status); + this.updateIcon(); + this.rebuildMenu(); + } + + private getOverallHealth(): ServiceHealth { + const { server, runner, redis } = this.status; + if (server === "error" || redis === "disconnected") return "error"; + if (server === "starting" || runner === "starting") return "degraded"; + if (server === "running" && runner === "running" && redis === "connected") return "healthy"; + return "degraded"; + } + + private updateIcon(): void { + const health = this.getOverallHealth(); + const iconName = + health === "error" ? "tray-error.png" : + health === "degraded" ? "tray-warning.png" : + "tray-default.png"; + + const iconPath = join(__dirname, "..", "..", "assets", iconName); + const icon = nativeImage.createFromPath(iconPath).resize({ width: 22, height: 22 }); + icon.setTemplateImage(true); + this.tray.setImage(icon); + } + + private statusIcon(val: string): string { + if (val === "running" || val === "connected") return "✓"; + if (val === "starting") return "…"; + return "✕"; + } + + private rebuildMenu(): void { + const menu = Menu.buildFromTemplate([ + { + label: this.window.isVisible() ? "Hide Window" : "Show Window", + click: () => { + if (this.window.isVisible()) { + this.window.hide(); + } else { + this.window.show(); + this.window.focus(); + } + }, + }, + { type: "separator" }, + { + label: `Server: localhost ${this.statusIcon(this.status.server)}`, + enabled: false, + }, + { + label: `Runner: ${this.status.runner} ${this.statusIcon(this.status.runner)}`, + enabled: false, + }, + { + label: `Redis: ${this.status.redis} ${this.statusIcon(this.status.redis)}`, + enabled: false, + }, + { type: "separator" }, + { + label: "Quit PizzaPi", + role: "quit", + }, + ]); + + this.tray.setContextMenu(menu); + } + + destroy(): void { + this.tray.destroy(); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/desktop/src/main/tray.ts packages/desktop/assets/ +git commit -m "feat(desktop): add system tray with health status" +``` + +--- + +### Task 6: Native notifications + +**Files:** +- Create: `packages/desktop/src/main/notifications.ts` + +- [ ] **Step 1: Implement `notifications.ts`** + +```typescript +// packages/desktop/src/main/notifications.ts +import { Notification, type BrowserWindow } from "electron"; +import log from "./logger.js"; + +export interface NotificationOptions { + title: string; + body: string; + /** If set, clicking the notification focuses the window. */ + window?: BrowserWindow; +} + +export function showNotification(opts: NotificationOptions): void { + if (!Notification.isSupported()) { + log.warn("Notifications not supported on this platform"); + return; + } + + const notification = new Notification({ + title: opts.title, + body: opts.body, + silent: false, + }); + + if (opts.window) { + notification.on("click", () => { + opts.window!.show(); + opts.window!.focus(); + }); + } + + notification.show(); +} + +export function notifySessionComplete(window: BrowserWindow, sessionName: string, duration: string): void { + showNotification({ + title: "Session Complete", + body: `Agent finished "${sessionName}" in ${duration}`, + window, + }); +} + +export function notifyAgentNeedsInput(window: BrowserWindow, sessionName: string): void { + showNotification({ + title: "Agent Needs Input", + body: `Session "${sessionName}" is waiting for your response`, + window, + }); +} + +export function notifyServiceError(window: BrowserWindow, error: string): void { + showNotification({ + title: "Service Error", + body: error, + window, + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/desktop/src/main/notifications.ts +git commit -m "feat(desktop): add native notification helpers" +``` + +--- + +### Task 7: Auto-launch + +**Files:** +- Create: `packages/desktop/src/main/auto-launch.ts` + +- [ ] **Step 1: Implement `auto-launch.ts`** + +```typescript +// packages/desktop/src/main/auto-launch.ts +import { app } from "electron"; +import log from "./logger.js"; + +export function getAutoLaunchEnabled(): boolean { + const settings = app.getLoginItemSettings(); + return settings.openAtLogin; +} + +export function setAutoLaunchEnabled(enabled: boolean): void { + log.info(`Setting auto-launch: ${enabled}`); + app.setLoginItemSettings({ + openAtLogin: enabled, + openAsHidden: true, // Start minimized to tray + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/desktop/src/main/auto-launch.ts +git commit -m "feat(desktop): add auto-launch login item support" +``` + +--- + +### Task 8: IPC handlers and preload script + +**Files:** +- Create: `packages/desktop/src/main/ipc.ts` +- Create: `packages/desktop/src/preload/index.ts` + +- [ ] **Step 1: Implement `ipc.ts`** + +```typescript +// packages/desktop/src/main/ipc.ts +import { ipcMain, type BrowserWindow } from "electron"; +import { app } from "electron"; +import { getAutoLaunchEnabled, setAutoLaunchEnabled } from "./auto-launch.js"; +import type { TrayStatus } from "./tray.js"; +import log from "./logger.js"; + +/** + * Register all IPC handlers. Call once at app startup. + */ +export function registerIpcHandlers(): void { + ipcMain.handle("desktop:getVersion", () => app.getVersion()); + ipcMain.handle("desktop:getPlatform", () => process.platform); + ipcMain.handle("desktop:getAutoLaunch", () => getAutoLaunchEnabled()); + ipcMain.handle("desktop:setAutoLaunch", (_event, enabled: boolean) => { + setAutoLaunchEnabled(enabled); + }); + + log.info("IPC handlers registered"); +} + +/** + * Send service status update to all renderer windows. + */ +export function sendServiceStatus(window: BrowserWindow, status: TrayStatus): void { + window.webContents.send("desktop:serviceStatus", status); +} +``` + +- [ ] **Step 2: Implement `preload/index.ts`** + +```typescript +// packages/desktop/src/preload/index.ts +import { contextBridge, ipcRenderer } from "electron"; + +export interface DesktopAPI { + getVersion(): Promise; + getPlatform(): Promise; + getAutoLaunch(): Promise; + setAutoLaunch(enabled: boolean): Promise; + onServiceStatus(callback: (status: any) => void): () => void; +} + +const desktopAPI: DesktopAPI = { + getVersion: () => ipcRenderer.invoke("desktop:getVersion"), + getPlatform: () => ipcRenderer.invoke("desktop:getPlatform"), + getAutoLaunch: () => ipcRenderer.invoke("desktop:getAutoLaunch"), + setAutoLaunch: (enabled) => ipcRenderer.invoke("desktop:setAutoLaunch", enabled), + onServiceStatus: (callback) => { + const handler = (_event: any, status: any) => callback(status); + ipcRenderer.on("desktop:serviceStatus", handler); + // Return cleanup function + return () => ipcRenderer.removeListener("desktop:serviceStatus", handler); + }, +}; + +contextBridge.exposeInMainWorld("desktopAPI", desktopAPI); +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/desktop/src/main/ipc.ts packages/desktop/src/preload/index.ts +git commit -m "feat(desktop): add IPC handlers and preload bridge" +``` + +--- + +### Task 9: Main process entry — tie everything together + +**Files:** +- Create: `packages/desktop/src/main/index.ts` + +- [ ] **Step 1: Implement `index.ts`** + +```typescript +// packages/desktop/src/main/index.ts +import { app, BrowserWindow, dialog } from "electron"; +import { join } from "node:path"; +import { ServerManager } from "./server-manager.js"; +import { RunnerManager } from "./runner-manager.js"; +import { AppTray } from "./tray.js"; +import { registerIpcHandlers, sendServiceStatus } from "./ipc.js"; +import { notifyServiceError } from "./notifications.js"; +import { isDev, DEFAULT_SERVER_PORT, VITE_DEV_URL, getUIDistPath } from "./config.js"; +import log from "./logger.js"; + +let mainWindow: BrowserWindow | null = null; +let tray: AppTray | null = null; +let serverManager: ServerManager | null = null; +let runnerManager: RunnerManager | null = null; + +function createWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 800, + minHeight: 600, + title: "PizzaPi", + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 16 }, + webPreferences: { + preload: join(__dirname, "..", "preload", "index.js"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + // Hide instead of close (app lives in tray) + win.on("close", (event) => { + if (!app.isQuitting) { + event.preventDefault(); + win.hide(); + } + }); + + return win; +} + +async function checkRedis(): Promise { + try { + // Quick TCP connect check to default Redis port + const net = await import("node:net"); + return new Promise((resolve) => { + const socket = net.createConnection({ port: 6379, host: "127.0.0.1" }); + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + resolve(false); + }); + socket.setTimeout(2000, () => { + socket.destroy(); + resolve(false); + }); + }); + } catch { + return false; + } +} + +async function startServices(): Promise { + if (!mainWindow) return; + + // Check Redis first + tray?.updateStatus({ redis: "disconnected", server: "starting" }); + + const redisAvailable = await checkRedis(); + if (!redisAvailable) { + tray?.updateStatus({ redis: "disconnected" }); + notifyServiceError(mainWindow, "Redis is not available. Please start Redis and relaunch."); + dialog.showErrorBox( + "Redis Required", + "PizzaPi requires Redis to be running.\n\nInstall with: brew install redis\nStart with: redis-server\n\nPlease start Redis and relaunch PizzaPi." + ); + return; + } + + tray?.updateStatus({ redis: "connected" }); + + // Start relay server + serverManager = new ServerManager({ port: DEFAULT_SERVER_PORT, isDev }); + tray?.updateStatus({ server: "starting" }); + + try { + await serverManager.start(); + tray?.updateStatus({ server: "running" }); + if (mainWindow) { + sendServiceStatus(mainWindow, { + server: "running", + runner: "starting", + redis: "connected", + }); + } + } catch (err) { + log.error("Failed to start server:", err); + tray?.updateStatus({ server: "error" }); + notifyServiceError(mainWindow!, `Server failed to start: ${err}`); + return; + } + + // Start runner daemon + runnerManager = new RunnerManager({ serverPort: DEFAULT_SERVER_PORT, isDev }); + tray?.updateStatus({ runner: "starting" }); + runnerManager.start(); + tray?.updateStatus({ runner: "running" }); + + if (mainWindow) { + sendServiceStatus(mainWindow, { + server: "running", + runner: "running", + redis: "connected", + }); + } + + // Load the UI + if (isDev) { + await mainWindow!.loadURL(VITE_DEV_URL); + mainWindow!.webContents.openDevTools(); + } else { + const uiPath = getUIDistPath(); + await mainWindow!.loadFile(join(uiPath, "index.html")); + } +} + +async function shutdown(): Promise { + log.info("Shutting down..."); + + if (runnerManager) { + runnerManager.stop(); + // Wait briefly for graceful shutdown + await new Promise((r) => setTimeout(r, 2000)); + runnerManager.forceKill(); + } + + if (serverManager) { + serverManager.stop(); + await new Promise((r) => setTimeout(r, 2000)); + serverManager.forceKill(); + } + + tray?.destroy(); +} + +// ── App lifecycle ───────────────────────────────────────────────────────────── + +// Extend app type to track quitting state +declare module "electron" { + interface App { + isQuitting: boolean; + } +} +app.isQuitting = false; + +app.on("before-quit", () => { + app.isQuitting = true; +}); + +app.whenReady().then(async () => { + log.info(`PizzaPi Desktop starting (dev=${isDev})...`); + + registerIpcHandlers(); + + mainWindow = createWindow(); + tray = new AppTray(mainWindow); + + await startServices(); +}); + +app.on("will-quit", async (event) => { + event.preventDefault(); + await shutdown(); + app.exit(0); +}); + +app.on("window-all-closed", () => { + // On macOS, don't quit when all windows are closed (app lives in tray) + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("activate", () => { + // On macOS, re-show the window when dock icon is clicked + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } +}); +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd packages/desktop && npx tsc --noEmit +``` + +Expected: no errors (or only errors related to electron types which we'll fix with the build). + +- [ ] **Step 3: Commit** + +```bash +git add packages/desktop/src/main/index.ts +git commit -m "feat(desktop): add main process entry with full lifecycle orchestration" +``` + +--- + +### Task 10: Dev smoke test — launch the Electron app + +- [ ] **Step 1: Build the desktop TypeScript** + +```bash +cd packages/desktop && bun run build +``` + +Expected: compiles to `dist/`. + +- [ ] **Step 2: Start Redis (if not running)** + +```bash +redis-server --daemonize yes +``` + +- [ ] **Step 3: Launch in dev mode** + +```bash +cd packages/desktop && bun run dev +``` + +Expected: Vite dev server starts, relay server starts, Electron window opens showing the PizzaPi UI. System tray icon appears with green status. + +- [ ] **Step 4: Verify tray menu** + +Click the tray icon → context menu should show server/runner/Redis status as connected. + +- [ ] **Step 5: Verify window hide/show** + +Close the window → app should stay in tray. Click tray icon → window reappears. Cmd+Q → app quits, all child processes cleaned up. + +- [ ] **Step 6: Commit any fixes from smoke test** + +```bash +git add -A +git commit -m "fix(desktop): smoke test fixes" +``` + +--- + +### Task 11: Run all tests and typecheck + +- [ ] **Step 1: Run desktop tests** + +```bash +cd packages/desktop && bun test +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run full repo typecheck** + +```bash +bun run typecheck +``` + +Expected: no new type errors. + +- [ ] **Step 3: Run full repo tests** + +```bash +bun run test +``` + +Expected: all existing tests still pass. + +- [ ] **Step 4: Final commit and push** + +```bash +git add -A +git commit -m "feat(desktop): Electron desktop client v1" +git push -u origin feat/electron-desktop-client +``` diff --git a/package.json b/package.json index dcd33aa4a..8682bf257 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "dev:server": "cd packages/server && bun run dev", "dev:ui": "cd packages/ui && bun run dev", "dev:cli": "bun packages/cli/src/index.ts", + "dev:desktop": "cd packages/desktop && bun run dev", + "build:desktop": "bun run build:ui && bun run build:server && bun run build:cli && cd packages/desktop && bun run build", + "package:desktop": "bun run build:desktop && cd packages/desktop && bun run package", "dev:runner": "bun packages/cli/src/index.ts runner", "test": "bun test packages/protocol/src packages/tunnel/src packages/server/src packages/server/tests packages/tools/src && cd packages/cli && bun test src && cd ../ui && bun test src", "typecheck": "bun packages/cli/scripts/compile-prompt.ts && tsc --build && bun run --cwd packages/protocol typecheck:tests && bun run --cwd packages/server typecheck:tests", @@ -53,5 +56,6 @@ "patchedDependencies": { "@mariozechner/pi-coding-agent@0.63.1": "patches/@mariozechner%2Fpi-coding-agent@0.63.1.patch", "@mariozechner/pi-ai@0.63.1": "patches/@mariozechner%2Fpi-ai@0.63.1.patch" - } + }, + } \ No newline at end of file diff --git a/packages/desktop/assets/icon.png b/packages/desktop/assets/icon.png new file mode 100644 index 000000000..65e3fd487 Binary files /dev/null and b/packages/desktop/assets/icon.png differ diff --git a/packages/desktop/assets/tray-default.png b/packages/desktop/assets/tray-default.png new file mode 100644 index 000000000..fbd5fcec2 Binary files /dev/null and b/packages/desktop/assets/tray-default.png differ diff --git a/packages/desktop/assets/tray-error.png b/packages/desktop/assets/tray-error.png new file mode 100644 index 000000000..fbd5fcec2 Binary files /dev/null and b/packages/desktop/assets/tray-error.png differ diff --git a/packages/desktop/assets/tray-warning.png b/packages/desktop/assets/tray-warning.png new file mode 100644 index 000000000..fbd5fcec2 Binary files /dev/null and b/packages/desktop/assets/tray-warning.png differ diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml new file mode 100644 index 000000000..933d1e80d --- /dev/null +++ b/packages/desktop/electron-builder.yml @@ -0,0 +1,37 @@ +appId: com.pizzapi.desktop +productName: PizzaPi +copyright: Copyright © 2026 PizzaPi + +directories: + output: release + +mac: + category: public.app-category.developer-tools + target: + - target: dir + arch: + - arm64 + icon: assets/icon.png + +files: + - dist/**/* + - assets/**/* + - package.json + # Bundle the built UI assets + - from: ../ui/dist + to: ui-dist + filter: + - "**/*" + # Bundle the built server + - from: ../server/dist + to: server-dist + filter: + - "**/*" + # Bundle the built CLI (runner) + - from: ../cli/dist + to: cli-dist + filter: + - "**/*" + +extraMetadata: + main: dist/main/index.js diff --git a/packages/desktop/package.json b/packages/desktop/package.json new file mode 100644 index 000000000..bb386a2fc --- /dev/null +++ b/packages/desktop/package.json @@ -0,0 +1,25 @@ +{ + "name": "@pizzapi/desktop", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/main/index.js", + "scripts": { + "dev": "concurrently \"bun run dev:electron\" \"bun run --cwd ../ui dev\" \"bun run --cwd ../server dev\" --kill-others-on-exit", + "dev:electron": "electron --inspect . --dev", + "build": "tsc --build", + "test": "bun test tests", + "typecheck": "tsc --noEmit", + "package": "electron-builder --mac", + "start": "electron ." + }, + "dependencies": { + "electron-log": "^5.3.0" + }, + "devDependencies": { + "electron": "^35.0.0", + "electron-builder": "^26.0.0", + "concurrently": "^9.2.1", + "typescript": "^5.7.0" + } +} diff --git a/packages/desktop/src/main/auto-launch.ts b/packages/desktop/src/main/auto-launch.ts new file mode 100644 index 000000000..e4b0d6bf7 --- /dev/null +++ b/packages/desktop/src/main/auto-launch.ts @@ -0,0 +1,16 @@ +// packages/desktop/src/main/auto-launch.ts +import { app } from "electron"; +import log from "./logger.js"; + +export function getAutoLaunchEnabled(): boolean { + const settings = app.getLoginItemSettings(); + return settings.openAtLogin; +} + +export function setAutoLaunchEnabled(enabled: boolean): void { + log.info(`Setting auto-launch: ${enabled}`); + app.setLoginItemSettings({ + openAtLogin: enabled, + openAsHidden: true, // Start minimized to tray + }); +} diff --git a/packages/desktop/src/main/config.ts b/packages/desktop/src/main/config.ts new file mode 100644 index 000000000..db79441d9 --- /dev/null +++ b/packages/desktop/src/main/config.ts @@ -0,0 +1,102 @@ +import { app } from "electron"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; +import { execFileSync } from "node:child_process"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** Whether we're running in dev mode (passed via --dev flag). */ +export const isDev = process.argv.includes("--dev"); + +/** Default port for the relay server. */ +export const DEFAULT_SERVER_PORT = 3001; + +/** Vite dev server URL (used in dev mode only). */ +export const VITE_DEV_URL = "http://localhost:5173"; + +/** + * Root of the packaged app. Uses app.getAppPath() which correctly resolves + * inside app.asar when asar packaging is enabled. + */ +function getAppRoot(): string { + return app.getAppPath(); +} + +/** Path to the bundled UI dist assets (production). */ +export function getUIDistPath(): string { + if (isDev) { + return join(__dirname, "..", "..", "..", "ui", "dist"); + } + return join(getAppRoot(), "ui-dist"); +} + +/** Path to the bundled server entry (production). */ +export function getServerEntryPath(): string { + if (isDev) { + return join(__dirname, "..", "..", "..", "server", "src", "index.ts"); + } + return join(getAppRoot(), "server-dist", "index.js"); +} + +/** Path to the bundled CLI entry for runner (production). */ +export function getRunnerEntryPath(): string { + if (isDev) { + return join(__dirname, "..", "..", "..", "cli", "src", "index.ts"); + } + return join(getAppRoot(), "cli-dist", "index.js"); +} + +/** + * Resolve the path to the Bun binary. Checks common locations and falls back + * to `which bun`. Throws a descriptive error if Bun is not found. + */ +export function getBunPath(): string { + // In dev, "bun" on PATH is fine + if (isDev) return "bun"; + + // Check well-known install locations + const candidates = [ + join(process.env.HOME ?? "", ".bun", "bin", "bun"), + "/usr/local/bin/bun", + "/opt/homebrew/bin/bun", + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + + // Try `which bun` as a fallback + try { + const result = execFileSync("which", ["bun"], { encoding: "utf8", timeout: 3000 }); + const resolved = result.trim(); + if (resolved && existsSync(resolved)) return resolved; + } catch { + // which not available or bun not found + } + + throw new Error( + "Bun runtime not found. PizzaPi requires Bun to run the relay server and runner daemon.\n\n" + + "Install Bun: curl -fsSL https://bun.sh/install | bash\n\n" + + "Then relaunch PizzaPi.", + ); +} + +/** App data directory. */ +export function getAppDataPath(): string { + return app.getPath("userData"); +} + +/** Logs directory. */ +export function getLogsPath(): string { + return app.getPath("logs"); +} + +/** Max restart attempts for child processes before showing error. */ +export const MAX_RESTART_ATTEMPTS = 3; + +/** Health check polling interval in ms. */ +export const HEALTH_CHECK_INTERVAL = 500; + +/** Health check timeout in ms. */ +export const HEALTH_CHECK_TIMEOUT = 30_000; diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts new file mode 100644 index 000000000..f84d1067d --- /dev/null +++ b/packages/desktop/src/main/index.ts @@ -0,0 +1,269 @@ +// packages/desktop/src/main/index.ts +import { app, BrowserWindow, dialog } from "electron"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +import { ServerManager } from "./server-manager.js"; +import { RunnerManager } from "./runner-manager.js"; +import { AppTray } from "./tray.js"; +import { registerIpcHandlers, sendServiceStatus } from "./ipc.js"; +import { notifyServiceError } from "./notifications.js"; +import { isDev, DEFAULT_SERVER_PORT, VITE_DEV_URL, getUIDistPath } from "./config.js"; +import log from "./logger.js"; + +// ── Suppress EPIPE errors ──────────────────────────────────────────────────── +// electron-log writes to stdout/stderr via console transports. When child +// processes (server, runner) exit, their piped streams close and the next +// console.warn/log call triggers an EPIPE. This is harmless — swallow it +// so Electron doesn't show the ugly crash dialog. +process.on("uncaughtException", (err: Error) => { + const code = (err as NodeJS.ErrnoException).code; + if (code === "EPIPE") return; // silently ignore + // For anything else, log and exit to avoid running in a corrupted state + log.error("Uncaught exception:", err); + process.exit(1); +}); + +let mainWindow: BrowserWindow | null = null; +let tray: AppTray | null = null; +let serverManager: ServerManager | null = null; +let runnerManager: RunnerManager | null = null; + +function createWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 800, + minHeight: 600, + title: "PizzaPi", + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 16 }, + webPreferences: { + preload: join(__dirname, "..", "preload", "index.js"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + // Make the top of the page draggable (window title bar region). + // Inject CSS after every page load so the user can drag the window + // from the top bar area, just like a native macOS app. + win.webContents.on("did-finish-load", () => { + win.webContents.insertCSS(` + /* Draggable title bar region for Electron */ + body::before { + content: ''; + display: block; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 38px; + -webkit-app-region: drag; + z-index: 99999; + pointer-events: auto; + } + /* Make interactive elements within the title bar area clickable */ + button, a, input, select, textarea, [role="button"], [role="menuitem"], + [data-radix-collection-item], [cmdk-input], [cmdk-item] { + -webkit-app-region: no-drag; + } + `); + }); + + // Hide instead of close (app lives in tray) + win.on("close", (event) => { + if (!isQuitting) { + event.preventDefault(); + win.hide(); + } + }); + + return win; +} + +async function checkRedis(): Promise { + try { + // Quick TCP connect check to default Redis port + const net = await import("node:net"); + return new Promise((resolve) => { + const socket = net.createConnection({ port: 6379, host: "127.0.0.1" }); + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + resolve(false); + }); + socket.setTimeout(2000, () => { + socket.destroy(); + resolve(false); + }); + }); + } catch { + return false; + } +} + +async function startServices(): Promise { + if (!mainWindow) return; + + // Check Redis first + tray?.updateStatus({ redis: "disconnected", server: "starting" }); + + const redisAvailable = await checkRedis(); + if (!redisAvailable) { + tray?.updateStatus({ redis: "disconnected" }); + notifyServiceError(mainWindow, "Redis is not available. Please start Redis and relaunch."); + dialog.showErrorBox( + "Redis Required", + "PizzaPi requires Redis to be running.\n\nInstall with: brew install redis\nStart with: redis-server\n\nPlease start Redis and relaunch PizzaPi." + ); + return; + } + + tray?.updateStatus({ redis: "connected" }); + + // Start relay server — try ports 3001-3010 + let serverPort = DEFAULT_SERVER_PORT; + tray?.updateStatus({ server: "starting" }); + + let serverStarted = false; + for (let port = DEFAULT_SERVER_PORT; port < DEFAULT_SERVER_PORT + 10; port++) { + serverManager = new ServerManager({ port, isDev }); + try { + await serverManager.start(); + serverPort = port; + serverStarted = true; + break; + } catch (err) { + const msg = String(err); + if (msg.includes("already in use") && port < DEFAULT_SERVER_PORT + 9) { + log.warn(`Port ${port} in use, trying ${port + 1}...`); + continue; + } + log.error("Failed to start server:", err); + tray?.updateStatus({ server: "error" }); + notifyServiceError(mainWindow!, `Server failed to start: ${err}`); + return; + } + } + + if (!serverStarted) { + tray?.updateStatus({ server: "error" }); + notifyServiceError(mainWindow!, "Could not find an available port for the server."); + return; + } + + log.info(`Server started on port ${serverPort}`); + tray?.updateStatus({ server: "running" }); + if (mainWindow) { + sendServiceStatus(mainWindow, { + server: "running", + runner: "starting", + redis: "connected", + }); + } + + // Start runner daemon + runnerManager = new RunnerManager({ serverPort, isDev }); + tray?.updateStatus({ runner: "starting" }); + try { + await runnerManager.start(); + tray?.updateStatus({ runner: "running" }); + } catch (err) { + log.error("Failed to start runner:", err); + tray?.updateStatus({ runner: "error" }); + // Non-fatal — the UI can still load, runner may recover via auto-restart + } + + if (mainWindow) { + sendServiceStatus(mainWindow, { + server: "running", + runner: "running", + redis: "connected", + }); + } + + // Load the UI + if (isDev) { + try { + // Try Vite dev server first (for HMR) + await mainWindow!.loadURL(VITE_DEV_URL); + mainWindow!.webContents.openDevTools(); + } catch { + // Vite not running — fall back to built UI assets or server URL + log.warn("Vite dev server not available, falling back to relay server UI"); + try { + await mainWindow!.loadURL(`http://localhost:${serverPort}`); + } catch (err) { + log.error("Failed to load UI:", err); + } + } + } else { + const uiPath = getUIDistPath(); + await mainWindow!.loadFile(join(uiPath, "index.html")); + } +} + +async function shutdown(): Promise { + log.info("Shutting down..."); + + if (runnerManager) { + runnerManager.stop(); + // Wait briefly for graceful shutdown + await new Promise((r) => setTimeout(r, 2000)); + runnerManager.forceKill(); + } + + if (serverManager) { + serverManager.stop(); + await new Promise((r) => setTimeout(r, 2000)); + serverManager.forceKill(); + } + + tray?.destroy(); +} + +// ── App lifecycle ───────────────────────────────────────────────────────────── + +// Track whether the app is in the process of quitting +let isQuitting = false; + +app.on("before-quit", () => { + isQuitting = true; +}); + +app.whenReady().then(async () => { + log.info(`PizzaPi Desktop starting (dev=${isDev})...`); + + registerIpcHandlers(); + + mainWindow = createWindow(); + tray = new AppTray(mainWindow); + + await startServices(); +}); + +app.on("will-quit", async (event) => { + event.preventDefault(); + await shutdown(); + app.exit(0); +}); + +app.on("window-all-closed", () => { + // On macOS, don't quit when all windows are closed (app lives in tray) + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("activate", () => { + // On macOS, re-show the window when dock icon is clicked + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } +}); diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts new file mode 100644 index 000000000..3a4f77888 --- /dev/null +++ b/packages/desktop/src/main/ipc.ts @@ -0,0 +1,27 @@ +// packages/desktop/src/main/ipc.ts +import { ipcMain, type BrowserWindow } from "electron"; +import { app } from "electron"; +import { getAutoLaunchEnabled, setAutoLaunchEnabled } from "./auto-launch.js"; +import type { TrayStatus } from "./tray.js"; +import log from "./logger.js"; + +/** + * Register all IPC handlers. Call once at app startup. + */ +export function registerIpcHandlers(): void { + ipcMain.handle("desktop:getVersion", () => app.getVersion()); + ipcMain.handle("desktop:getPlatform", () => process.platform); + ipcMain.handle("desktop:getAutoLaunch", () => getAutoLaunchEnabled()); + ipcMain.handle("desktop:setAutoLaunch", (_event, enabled: boolean) => { + setAutoLaunchEnabled(enabled); + }); + + log.info("IPC handlers registered"); +} + +/** + * Send service status update to all renderer windows. + */ +export function sendServiceStatus(window: BrowserWindow, status: TrayStatus): void { + window.webContents.send("desktop:serviceStatus", status); +} diff --git a/packages/desktop/src/main/logger.ts b/packages/desktop/src/main/logger.ts new file mode 100644 index 000000000..4e0cd5517 --- /dev/null +++ b/packages/desktop/src/main/logger.ts @@ -0,0 +1,7 @@ +import log from "electron-log/main.js"; + +log.initialize(); +log.transports.file.level = "info"; +log.transports.console.level = "debug"; + +export default log; diff --git a/packages/desktop/src/main/notifications.ts b/packages/desktop/src/main/notifications.ts new file mode 100644 index 000000000..56a3aeb79 --- /dev/null +++ b/packages/desktop/src/main/notifications.ts @@ -0,0 +1,56 @@ +// packages/desktop/src/main/notifications.ts +import { Notification, type BrowserWindow } from "electron"; +import log from "./logger.js"; + +export interface NotificationOptions { + title: string; + body: string; + /** If set, clicking the notification focuses the window. */ + window?: BrowserWindow; +} + +export function showNotification(opts: NotificationOptions): void { + if (!Notification.isSupported()) { + log.warn("Notifications not supported on this platform"); + return; + } + + const notification = new Notification({ + title: opts.title, + body: opts.body, + silent: false, + }); + + if (opts.window) { + notification.on("click", () => { + opts.window!.show(); + opts.window!.focus(); + }); + } + + notification.show(); +} + +export function notifySessionComplete(window: BrowserWindow, sessionName: string, duration: string): void { + showNotification({ + title: "Session Complete", + body: `Agent finished "${sessionName}" in ${duration}`, + window, + }); +} + +export function notifyAgentNeedsInput(window: BrowserWindow, sessionName: string): void { + showNotification({ + title: "Agent Needs Input", + body: `Session "${sessionName}" is waiting for your response`, + window, + }); +} + +export function notifyServiceError(window: BrowserWindow, error: string): void { + showNotification({ + title: "Service Error", + body: error, + window, + }); +} diff --git a/packages/desktop/src/main/runner-manager.ts b/packages/desktop/src/main/runner-manager.ts new file mode 100644 index 000000000..b6fde2678 --- /dev/null +++ b/packages/desktop/src/main/runner-manager.ts @@ -0,0 +1,121 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { getRunnerEntryPath, getBunPath, MAX_RESTART_ATTEMPTS, HEALTH_CHECK_TIMEOUT } from "./config.js"; +import log from "./logger.js"; + +export interface RunnerManagerOptions { + serverPort: number; + isDev: boolean; +} + +export class RunnerManager { + private child: ChildProcess | null = null; + private serverPort: number; + private isDev: boolean; + private restartCount = 0; + private stopping = false; + + constructor(opts: RunnerManagerOptions) { + this.serverPort = opts.serverPort; + this.isDev = opts.isDev; + } + + /** + * Spawn the runner daemon and wait for it to register with the server. + * Polls /api/runners until the runner appears, or times out. + */ + async start(): Promise { + this.stopping = false; + const entry = getRunnerEntryPath(); + log.info("Starting runner daemon..."); + + let earlyExit = false; + + const env = { + ...process.env, + PIZZAPI_SERVER_URL: `http://localhost:${this.serverPort}`, + }; + + const bunPath = getBunPath(); + this.child = spawn(bunPath, ["run", entry, "runner"], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + + this.child.stdout?.on("data", (data: Buffer) => { + log.info(`[runner] ${data.toString().trim()}`); + }); + + this.child.stderr?.on("data", (data: Buffer) => { + log.warn(`[runner] ${data.toString().trim()}`); + }); + + this.child.on("exit", (code, signal) => { + log.info(`Runner exited: code=${code} signal=${signal}`); + earlyExit = true; + this.child = null; + if (!this.stopping && this.restartCount < MAX_RESTART_ATTEMPTS) { + this.restartCount++; + log.warn(`Restarting runner (attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`); + this.start().catch((err) => log.error("Runner restart failed:", err)); + } + }); + + // Wait for the runner to register with the server (poll /api/runners) + await this.waitForReady(() => earlyExit); + log.info("Runner daemon is ready"); + } + + /** + * Poll the server's /api/runners endpoint until at least one runner + * appears, indicating the daemon has connected. Times out after + * HEALTH_CHECK_TIMEOUT ms. + */ + private async waitForReady(hasExited: () => boolean): Promise { + const deadline = Date.now() + HEALTH_CHECK_TIMEOUT; + while (Date.now() < deadline) { + if (hasExited()) { + throw new Error("Runner process exited before becoming ready"); + } + try { + const res = await fetch(`http://localhost:${this.serverPort}/api/runners`, { + headers: { "Content-Type": "application/json" }, + }); + if (res.ok) { + const body = await res.json() as any; + if (Array.isArray(body?.runners) && body.runners.length > 0) { + return; + } + } + } catch { + // Server not ready or runner not registered yet + } + await new Promise((r) => setTimeout(r, 500)); + } + // Don't throw — runner may still be starting up and will register soon. + // Just warn so the caller can mark it as running optimistically. + log.warn("Runner did not register within timeout, continuing..."); + } + + /** Gracefully stop the runner. */ + stop(): void { + this.stopping = true; + if (this.child) { + log.info("Stopping runner daemon..."); + this.child.kill("SIGTERM"); + this.child = null; + } + } + + /** Force-kill if still running. */ + forceKill(): void { + this.stopping = true; + if (this.child) { + this.child.kill("SIGKILL"); + this.child = null; + } + } + + isRunning(): boolean { + return this.child !== null; + } +} diff --git a/packages/desktop/src/main/server-manager.ts b/packages/desktop/src/main/server-manager.ts new file mode 100644 index 000000000..52346e3ba --- /dev/null +++ b/packages/desktop/src/main/server-manager.ts @@ -0,0 +1,144 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { + getServerEntryPath, + getBunPath, + HEALTH_CHECK_INTERVAL, + HEALTH_CHECK_TIMEOUT, + MAX_RESTART_ATTEMPTS, +} from "./config.js"; +import log from "./logger.js"; + +export interface ServerManagerOptions { + port: number; + isDev: boolean; +} + +export class ServerManager { + private child: ChildProcess | null = null; + private port: number; + private isDev: boolean; + private restartCount = 0; + private stopping = false; + private initialStartup = false; + + constructor(opts: ServerManagerOptions) { + this.port = opts.port; + this.isDev = opts.isDev; + } + + /** Spawn the relay server and wait for it to become healthy. */ + async start(): Promise { + this.stopping = false; + this.initialStartup = true; + + // Check if port is available before spawning + const portFree = await this.isPortFree(this.port); + if (!portFree) { + throw new Error(`Port ${this.port} is already in use. Stop the existing server or use a different port.`); + } + + const entry = getServerEntryPath(); + log.info(`Starting relay server on port ${this.port}...`); + + const env = { + ...process.env, + PORT: String(this.port), + NODE_ENV: this.isDev ? "development" : "production", + }; + + // Track if the child exits early (before health check passes) + let earlyExit = false; + let earlyExitCode: number | null = null; + + const bunPath = getBunPath(); + this.child = spawn(bunPath, ["run", entry], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + + this.child.stdout?.on("data", (data: Buffer) => { + log.info(`[server] ${data.toString().trim()}`); + }); + + this.child.stderr?.on("data", (data: Buffer) => { + log.warn(`[server] ${data.toString().trim()}`); + }); + + this.child.on("exit", (code, signal) => { + log.info(`Server exited: code=${code} signal=${signal}`); + earlyExit = true; + earlyExitCode = code; + this.child = null; + // Don't auto-restart during initial startup — let start() reject cleanly + // so the caller can handle the error without a background restart race. + if (!this.stopping && !this.initialStartup && this.restartCount < MAX_RESTART_ATTEMPTS) { + this.restartCount++; + log.warn(`Restarting server (attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`); + this.start().catch((err) => log.error("Server restart failed:", err)); + } + }); + + await this.waitForHealthy(() => earlyExit); + this.initialStartup = false; + this.restartCount = 0; + log.info(`Relay server healthy on port ${this.port}`); + } + + /** Check if a port is free. */ + private async isPortFree(port: number): Promise { + const net = await import("node:net"); + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); + } + + /** Poll /health until 200 or timeout, aborting if the child exits early. */ + private async waitForHealthy(hasExited: () => boolean): Promise { + const deadline = Date.now() + HEALTH_CHECK_TIMEOUT; + while (Date.now() < deadline) { + if (hasExited()) { + throw new Error("Server process exited before becoming healthy"); + } + try { + const res = await fetch(`http://localhost:${this.port}/health`); + if (res.ok) return; + } catch { + // Server not ready yet + } + await new Promise((r) => setTimeout(r, HEALTH_CHECK_INTERVAL)); + } + throw new Error(`Server failed to become healthy within ${HEALTH_CHECK_TIMEOUT}ms`); + } + + /** Gracefully stop the server. */ + stop(): void { + this.stopping = true; + if (this.child) { + log.info("Stopping relay server..."); + this.child.kill("SIGTERM"); + this.child = null; + } + } + + /** Force-kill if still running. */ + forceKill(): void { + this.stopping = true; + if (this.child) { + this.child.kill("SIGKILL"); + this.child = null; + } + } + + isRunning(): boolean { + return this.child !== null; + } + + getPort(): number { + return this.port; + } +} diff --git a/packages/desktop/src/main/tray.ts b/packages/desktop/src/main/tray.ts new file mode 100644 index 000000000..8b42de883 --- /dev/null +++ b/packages/desktop/src/main/tray.ts @@ -0,0 +1,119 @@ +// packages/desktop/src/main/tray.ts +import { Tray, Menu, nativeImage, type BrowserWindow } from "electron"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +import log from "./logger.js"; +import type { ServiceStatus } from "../shared/types.js"; + +export type ServiceHealth = "healthy" | "degraded" | "error"; + +/** @deprecated Use ServiceStatus from shared/types.ts */ +export type TrayStatus = ServiceStatus; + +export class AppTray { + private tray: Tray; + private window: BrowserWindow; + private status: TrayStatus = { + server: "stopped", + runner: "stopped", + redis: "disconnected", + }; + + constructor(window: BrowserWindow) { + this.window = window; + + const iconPath = join(__dirname, "..", "..", "assets", "tray-default.png"); + const icon = nativeImage.createFromPath(iconPath).resize({ width: 22, height: 22 }); + icon.setTemplateImage(true); + + this.tray = new Tray(icon); + this.tray.setToolTip("PizzaPi"); + + this.tray.on("click", () => { + if (this.window.isVisible()) { + this.window.hide(); + } else { + this.window.show(); + this.window.focus(); + } + }); + + this.rebuildMenu(); + } + + updateStatus(status: Partial): void { + Object.assign(this.status, status); + this.updateIcon(); + this.rebuildMenu(); + } + + private getOverallHealth(): ServiceHealth { + const { server, runner, redis } = this.status; + if (server === "error" || redis === "disconnected") return "error"; + if (server === "starting" || runner === "starting") return "degraded"; + if (server === "running" && runner === "running" && redis === "connected") return "healthy"; + return "degraded"; + } + + private updateIcon(): void { + const health = this.getOverallHealth(); + const iconName = + health === "error" ? "tray-error.png" : + health === "degraded" ? "tray-warning.png" : + "tray-default.png"; + + const iconPath = join(__dirname, "..", "..", "assets", iconName); + const icon = nativeImage.createFromPath(iconPath).resize({ width: 22, height: 22 }); + icon.setTemplateImage(true); + this.tray.setImage(icon); + } + + private statusIcon(val: string): string { + if (val === "running" || val === "connected") return "\u2713"; + if (val === "starting") return "\u2026"; + return "\u2715"; + } + + private rebuildMenu(): void { + const menu = Menu.buildFromTemplate([ + { + label: this.window.isVisible() ? "Hide Window" : "Show Window", + click: () => { + if (this.window.isVisible()) { + this.window.hide(); + } else { + this.window.show(); + this.window.focus(); + } + }, + }, + { type: "separator" }, + { + label: `Server: localhost ${this.statusIcon(this.status.server)}`, + enabled: false, + }, + { + label: `Runner: ${this.status.runner} ${this.statusIcon(this.status.runner)}`, + enabled: false, + }, + { + label: `Redis: ${this.status.redis} ${this.statusIcon(this.status.redis)}`, + enabled: false, + }, + { type: "separator" }, + { + label: "Quit PizzaPi", + role: "quit", + }, + ]); + + this.tray.setContextMenu(menu); + } + + destroy(): void { + this.tray.destroy(); + } +} diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts new file mode 100644 index 000000000..936b7c94f --- /dev/null +++ b/packages/desktop/src/preload/index.ts @@ -0,0 +1,26 @@ +// packages/desktop/src/preload/index.ts +import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; +import type { ServiceStatus } from "../shared/types.js"; + +export interface DesktopAPI { + getVersion(): Promise; + getPlatform(): Promise; + getAutoLaunch(): Promise; + setAutoLaunch(enabled: boolean): Promise; + onServiceStatus(callback: (status: ServiceStatus) => void): () => void; +} + +const desktopAPI: DesktopAPI = { + getVersion: () => ipcRenderer.invoke("desktop:getVersion"), + getPlatform: () => ipcRenderer.invoke("desktop:getPlatform"), + getAutoLaunch: () => ipcRenderer.invoke("desktop:getAutoLaunch"), + setAutoLaunch: (enabled) => ipcRenderer.invoke("desktop:setAutoLaunch", enabled), + onServiceStatus: (callback) => { + const handler = (_event: IpcRendererEvent, status: ServiceStatus) => callback(status); + ipcRenderer.on("desktop:serviceStatus", handler); + // Return cleanup function + return () => ipcRenderer.removeListener("desktop:serviceStatus", handler); + }, +}; + +contextBridge.exposeInMainWorld("desktopAPI", desktopAPI); diff --git a/packages/desktop/src/shared/types.ts b/packages/desktop/src/shared/types.ts new file mode 100644 index 000000000..047dd8f17 --- /dev/null +++ b/packages/desktop/src/shared/types.ts @@ -0,0 +1,11 @@ +/** + * Shared types between main, preload, and renderer processes. + * Keep this file free of Electron imports so it can be used in any context. + */ + +/** Service status sent from main → renderer via IPC. */ +export interface ServiceStatus { + server: "starting" | "running" | "error" | "stopped"; + runner: "starting" | "running" | "error" | "stopped"; + redis: "connected" | "disconnected"; +} diff --git a/packages/desktop/tests/runner-manager.test.ts b/packages/desktop/tests/runner-manager.test.ts new file mode 100644 index 000000000..f85be9942 --- /dev/null +++ b/packages/desktop/tests/runner-manager.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test"; + +// Mock electron-log/main before anything imports logger +mock.module("electron-log/main", () => ({ + default: { + initialize: mock(() => {}), + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + transports: { + file: { level: "info" }, + console: { level: "debug" }, + }, + }, +})); + +// Mock electron app module (used by config.ts) +mock.module("electron", () => ({ + app: { + getPath: mock(() => "/tmp/test"), + }, +})); + +// Mock config to avoid electron dependency in test +mock.module("../src/main/config.js", () => ({ + getRunnerEntryPath: () => "/fake/runner/index.js", + getServerEntryPath: () => "/fake/server/index.ts", + getBunPath: () => "bun", + MAX_RESTART_ATTEMPTS: 3, + HEALTH_CHECK_INTERVAL: 10, + HEALTH_CHECK_TIMEOUT: 500, + isDev: true, +})); + +const mockKill = mock(() => true); +const mockStdoutOn = mock(() => {}); +const mockStderrOn = mock(() => {}); +const mockOn = mock(() => {}); + +const mockSpawn = mock(() => ({ + pid: 5678, + on: mockOn, + kill: mockKill, + stdout: { on: mockStdoutOn }, + stderr: { on: mockStderrOn }, +})); + +mock.module("node:child_process", () => ({ + spawn: mockSpawn, +})); + +describe("RunnerManager", () => { + beforeEach(() => { + mockSpawn.mockClear(); + mockKill.mockClear(); + mockOn.mockClear(); + }); + + test("start() spawns the runner daemon pointing at the local server", async () => { + const { RunnerManager } = await import("../src/main/runner-manager.js"); + const mgr = new RunnerManager({ serverPort: 3001, isDev: true }); + + // Mock fetch so waitForReady sees a registered runner + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ runners: [{ runnerId: "test" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) + ) as any; + + await mgr.start(); + + expect(mockSpawn).toHaveBeenCalled(); + expect(mgr.isRunning()).toBe(true); + + globalThis.fetch = originalFetch; + }); + + test("stop() sends SIGTERM to runner", async () => { + const { RunnerManager } = await import("../src/main/runner-manager.js"); + const mgr = new RunnerManager({ serverPort: 3001, isDev: true }); + + // Mock fetch so start() completes + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ runners: [{ runnerId: "test" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) + ) as any; + + await mgr.start(); + mgr.stop(); + + globalThis.fetch = originalFetch; + + expect(mockKill).toHaveBeenCalledWith("SIGTERM"); + expect(mgr.isRunning()).toBe(false); + }); +}); diff --git a/packages/desktop/tests/server-manager.test.ts b/packages/desktop/tests/server-manager.test.ts new file mode 100644 index 000000000..cb47c5a55 --- /dev/null +++ b/packages/desktop/tests/server-manager.test.ts @@ -0,0 +1,104 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test"; + +// Mock electron-log/main before anything imports logger +mock.module("electron-log/main", () => { + const noop = () => {}; + const log = { + info: noop, + warn: noop, + error: noop, + debug: noop, + initialize: noop, + transports: { + file: { level: "info" }, + console: { level: "debug" }, + }, + }; + return { default: log }; +}); + +// Mock electron app module used by config +mock.module("electron", () => ({ + app: { + getPath: () => "/tmp/test", + }, +})); + +// Mock config to avoid electron dependency in test context. +// Include ALL exports — bun test shares modules across files in the same run. +mock.module("../src/main/config.js", () => ({ + getServerEntryPath: () => "/fake/server/index.ts", + getRunnerEntryPath: () => "/fake/runner/index.js", + getUIDistPath: () => "/fake/ui/dist", + getBunPath: () => "bun", + HEALTH_CHECK_INTERVAL: 10, + HEALTH_CHECK_TIMEOUT: 1000, + MAX_RESTART_ATTEMPTS: 3, + DEFAULT_SERVER_PORT: 3001, + VITE_DEV_URL: "http://localhost:5173", + isDev: true, +})); + +// Mock child_process.spawn +const mockKill = mock(() => true); +const mockSpawn = mock(() => ({ + pid: 1234, + on: mock(() => {}), + kill: mockKill, + stdout: { on: mock(() => {}) }, + stderr: { on: mock(() => {}) }, +})); + +mock.module("node:child_process", () => ({ + spawn: mockSpawn, +})); + +describe("ServerManager", () => { + beforeEach(() => { + mockSpawn.mockClear(); + mockKill.mockClear(); + }); + + test("start() spawns a child process with the correct entry path", async () => { + const { ServerManager } = await import("../src/main/server-manager.js"); + const port = 10000 + Math.floor(Math.random() * 50000); + const mgr = new ServerManager({ port, isDev: true }); + + // Mock fetch for health check + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response("ok", { status: 200 })) + ) as any; + + await mgr.start(); + + expect(mockSpawn).toHaveBeenCalled(); + expect(mgr.isRunning()).toBe(true); + + globalThis.fetch = originalFetch; + }); + + test("stop() sends SIGTERM to the child process", async () => { + const { ServerManager } = await import("../src/main/server-manager.js"); + const port = 10000 + Math.floor(Math.random() * 50000); + const mgr = new ServerManager({ port, isDev: true }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response("ok", { status: 200 })) + ) as any; + + await mgr.start(); + mgr.stop(); + + expect(mgr.isRunning()).toBe(false); + + globalThis.fetch = originalFetch; + }); + + test("getPort() returns the configured port", async () => { + const { ServerManager } = await import("../src/main/server-manager.js"); + const mgr = new ServerManager({ port: 3042, isDev: true }); + expect(mgr.getPort()).toBe(3042); + }); +}); diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json new file mode 100644 index 000000000..84703dec7 --- /dev/null +++ b/packages/desktop/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2022", + "declaration": true, + "sourceMap": true + }, + "include": ["src"] +}