fox-desktop/docs/superpowers/plans/2026-05-11-fox-desktop.md

860 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Fox Desktop 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:** Electron-App als native Mac-Hülle, die `https://fox.pxo.at/hud` in einem transparenten Fullscreen-Overlay lädt und native Mac-Fähigkeiten hinzufügt (Tray, Wakeword, AppleScript, LaunchAgent).
**Architecture:** Electron lädt `fox.pxo.at/hud` als remote URL in einem rahmenlosen transparenten BrowserWindow. `preload.js` injiziert `window.foxBridge` in die HUD-Seite für native Mac-Befehle via IPC. Ein Python-Subprocess mit OpenWakeWord erkennt "hey jarvis" und löst `showHud()` aus. Kein eigener WebSocket in Electron — der Reverb-Kanal läuft komplett in der HUD-Seite.
**Tech Stack:** Electron 28, Node.js (child_process, IPC), Jest 29, Python 3 + openwakeword + pyaudio + onnxruntime
**Spec:** `docs/superpowers/specs/2026-05-11-fox-desktop-design.md`
---
## Dateien
| Datei | Aufgabe |
|---|---|
| `package.json` | App-Konfig, Scripts, Dependencies |
| `.gitignore` | node_modules, dist etc. |
| `electron/main.js` | App-Lifecycle, IPC-Handler, globaler Shortcut |
| `electron/hud.js` | BrowserWindow (remote URL, transparent, always-on-top) |
| `electron/tray.js` | Menu Bar Icon + Kontextmenü |
| `electron/wakeword.js` | Python-Subprocess starten/auto-restarten |
| `electron/mac-agent.js` | AppleScript-Dispatch (resolveCommand + executeMacCommand) |
| `electron/preload.js` | `window.foxBridge` in HUD-Seite injizieren |
| `python/wakeword.py` | OpenWakeWord Loop, schreibt "WAKE" auf stdout |
| `assets/fox-tray.png` | 16×16 Platzhalter-Icon |
| `__tests__/mac-agent.test.js` | Unit-Tests für mac-agent.js |
| `launchagent/at.pxo.fox.plist` | LaunchAgent-Vorlage für Auto-Start beim Login |
| `docs/hud-bridge-integration.md` | Anleitung für Laravel HUD Anpassungen |
---
### Task 1: Projekt-Scaffolding
**Files:**
- Create: `package.json`
- Create: `.gitignore`
- [ ] **Step 1: package.json erstellen**
`package.json`:
```json
{
"name": "fox-desktop",
"version": "1.0.0",
"description": "Fox — Persönlicher KI-Assistent",
"main": "electron/main.js",
"scripts": {
"start": "electron .",
"test": "jest"
},
"dependencies": {
"axios": "^1.6.0"
},
"devDependencies": {
"electron": "^28.0.0",
"jest": "^29.0.0"
},
"jest": {
"testEnvironment": "node",
"testMatch": ["**/__tests__/**/*.test.js"]
}
}
```
- [ ] **Step 2: .gitignore erstellen**
`.gitignore`:
```
node_modules/
dist/
*.log
.env
```
- [ ] **Step 3: Verzeichnisse anlegen**
```bash
mkdir -p electron python assets __tests__ launchagent docs
```
Expected: kein Fehler.
- [ ] **Step 4: Dependencies installieren**
```bash
npm install
```
Expected: `node_modules/` wird erstellt, keine Fehler.
- [ ] **Step 5: Commit**
```bash
git add package.json package-lock.json .gitignore
git commit -m "feat: project scaffolding"
```
---
### Task 2: Platzhalter-Icon
**Files:**
- Create: `assets/fox-tray.png`
- [ ] **Step 1: 16×16 PNG via Python generieren**
```bash
python3 -c "
import struct, zlib
def mkpng(w, h, rgb):
def chunk(t, d):
c = struct.pack('>I', len(d)) + t + d
return c + struct.pack('>I', zlib.crc32(t + d) & 0xffffffff)
ihdr = struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)
row = b'\x00' + bytes(rgb) * w
idat = zlib.compress(row * h)
return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', ihdr) + chunk(b'IDAT', idat) + chunk(b'IEND', b'')
open('assets/fox-tray.png', 'wb').write(mkpng(16, 16, [255, 165, 0]))
print('Icon erstellt')
"
```
Expected: `Icon erstellt``assets/fox-tray.png` existiert.
- [ ] **Step 2: Commit**
```bash
git add assets/fox-tray.png
git commit -m "feat: add placeholder tray icon"
```
---
### Task 3: mac-agent.js — TDD
**Files:**
- Create: `__tests__/mac-agent.test.js`
- Create: `electron/mac-agent.js`
- [ ] **Step 1: Fehlschlagenden Test schreiben**
`__tests__/mac-agent.test.js`:
```javascript
const { resolveCommand } = require('../electron/mac-agent');
describe('resolveCommand', () => {
test('bekannter Shortcut → applescript', () => {
expect(resolveCommand('spotify_play')).toEqual({
type: 'applescript',
script: 'tell application "Spotify" to play',
});
});
test('script: Präfix → applescript mit direktem Script', () => {
expect(resolveCommand('script:tell application "Finder" to activate')).toEqual({
type: 'applescript',
script: 'tell application "Finder" to activate',
});
});
test('open: Präfix → URL öffnen', () => {
expect(resolveCommand('open:https://example.com')).toEqual({
type: 'open',
url: 'https://example.com',
});
});
test('app: Präfix → App öffnen', () => {
expect(resolveCommand('app:Spotify')).toEqual({
type: 'app',
name: 'Spotify',
});
});
test('save: Präfix → Datei auf Desktop speichern', () => {
expect(resolveCommand('save:{"filename":"test.txt","content":"hallo"}')).toEqual({
type: 'save',
filename: 'test.txt',
content: 'hallo',
});
});
test('unbekannter Befehl → unknown', () => {
expect(resolveCommand('gibts_nicht')).toEqual({ type: 'unknown' });
});
test('volume_up ist bekannter Shortcut', () => {
const result = resolveCommand('volume_up');
expect(result.type).toBe('applescript');
expect(result.script).toContain('output volume');
});
});
```
- [ ] **Step 2: Test ausführen — erwartet FAIL**
```bash
npm test
```
Expected: `Cannot find module '../electron/mac-agent'`
- [ ] **Step 3: mac-agent.js implementieren**
`electron/mac-agent.js`:
```javascript
const { exec, execSync } = require('child_process');
const util = require('util');
const fs = require('fs');
const execAsync = util.promisify(exec);
const SCRIPTS = {
spotify_play: 'tell application "Spotify" to play',
spotify_pause: 'tell application "Spotify" to pause',
spotify_next: 'tell application "Spotify" to next track',
spotify_prev: 'tell application "Spotify" to previous track',
music_play: 'tell application "Music" to play',
music_pause: 'tell application "Music" to pause',
music_next: 'tell application "Music" to next track',
mail_open: 'tell application "Mail" to activate',
mail_unread: 'tell application "Mail" to return (count of (messages of inbox whose read status is false))',
calendar_open: 'tell application "Calendar" to activate',
notes_open: 'tell application "Notes" to activate',
volume_up: 'set volume output volume (output volume of (get volume settings) + 10)',
volume_down: 'set volume output volume (output volume of (get volume settings) - 10)',
volume_mute: 'set volume output muted true',
volume_unmute: 'set volume output muted false',
dark_mode_on: 'tell app "System Events" to tell appearance preferences to set dark mode to true',
dark_mode_off: 'tell app "System Events" to tell appearance preferences to set dark mode to false',
screenshot: 'do shell script "screencapture -x ~/Desktop/fox-screenshot-$(date +%Y%m%d-%H%M%S).png"',
};
function resolveCommand(command) {
if (SCRIPTS[command]) {
return { type: 'applescript', script: SCRIPTS[command] };
}
if (command.startsWith('script:')) {
return { type: 'applescript', script: command.slice(7) };
}
if (command.startsWith('open:')) {
return { type: 'open', url: command.slice(5) };
}
if (command.startsWith('app:')) {
return { type: 'app', name: command.slice(4) };
}
if (command.startsWith('save:')) {
return { type: 'save', ...JSON.parse(command.slice(5)) };
}
return { type: 'unknown' };
}
async function executeMacCommand(command) {
try {
const resolved = resolveCommand(command);
if (resolved.type === 'applescript') {
const { stdout } = await execAsync(`osascript -e '${resolved.script}'`);
return { success: true, output: stdout.trim() };
}
if (resolved.type === 'open') {
execSync(`open "${resolved.url}"`);
return { success: true, output: 'Geöffnet' };
}
if (resolved.type === 'app') {
execSync(`open -a "${resolved.name}"`);
return { success: true, output: `${resolved.name} geöffnet` };
}
if (resolved.type === 'save') {
const filePath = `${process.env.HOME}/Desktop/${resolved.filename}`;
fs.writeFileSync(filePath, resolved.content, 'utf8');
return { success: true, output: `Gespeichert: ${filePath}` };
}
return { success: false, output: 'Unbekannter Befehl' };
} catch (err) {
return { success: false, output: err.message };
}
}
module.exports = { executeMacCommand, resolveCommand, SCRIPTS };
```
- [ ] **Step 4: Tests ausführen — erwartet PASS**
```bash
npm test
```
Expected:
```
PASS __tests__/mac-agent.test.js
resolveCommand
✓ bekannter Shortcut → applescript
✓ script: Präfix → applescript mit direktem Script
✓ open: Präfix → URL öffnen
✓ app: Präfix → App öffnen
✓ save: Präfix → Datei auf Desktop speichern
✓ unbekannter Befehl → unknown
✓ volume_up ist bekannter Shortcut
Tests: 7 passed, 7 total
```
- [ ] **Step 5: Commit**
```bash
git add electron/mac-agent.js __tests__/mac-agent.test.js
git commit -m "feat: mac-agent with AppleScript dispatch and unit tests"
```
---
### Task 4: preload.js — foxBridge
**Files:**
- Create: `electron/preload.js`
- [ ] **Step 1: preload.js erstellen**
`electron/preload.js`:
```javascript
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('foxBridge', {
sendMessage: (text) => ipcRenderer.send('send-message', text),
hideHud: () => ipcRenderer.send('hide-hud'),
macCommand: (cmd) => ipcRenderer.invoke('mac-command', cmd),
onFoxAction: (cb) => ipcRenderer.on('fox-action', (_, data) => cb(data)),
onHudShow: (cb) => ipcRenderer.on('hud-show', () => cb()),
onHudHide: (cb) => ipcRenderer.on('hud-hide', () => cb()),
onStartListening: (cb) => ipcRenderer.on('start-listening', () => cb()),
onPlayAudio: (cb) => ipcRenderer.on('play-audio', (_, url) => cb(url)),
});
```
- [ ] **Step 2: Commit**
```bash
git add electron/preload.js
git commit -m "feat: preload — injects window.foxBridge into remote HUD"
```
---
### Task 5: hud.js — BrowserWindow
**Files:**
- Create: `electron/hud.js`
- [ ] **Step 1: hud.js erstellen**
`electron/hud.js`:
```javascript
const { BrowserWindow, screen } = require('electron');
const path = require('path');
const HUD_URL = process.env.FOX_HUD_URL || 'https://fox.pxo.at/hud';
let hudWindow = null;
function createHud() {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
hudWindow = new BrowserWindow({
width,
height,
x: 0,
y: 0,
frame: false,
transparent: true,
alwaysOnTop: true,
show: false,
skipTaskbar: true,
hasShadow: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: false,
allowRunningInsecureContent: true,
},
});
hudWindow.setAlwaysOnTop(true, 'screen-saver');
hudWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
hudWindow.loadURL(HUD_URL);
hudWindow.on('closed', () => { hudWindow = null; });
return hudWindow;
}
function showHud() {
if (!hudWindow) createHud();
hudWindow.show();
hudWindow.focus();
hudWindow.webContents.send('hud-show');
}
function hideHud() {
if (!hudWindow) return;
hudWindow.webContents.send('hud-hide');
setTimeout(() => {
if (hudWindow) hudWindow.hide();
}, 600);
}
module.exports = { createHud, showHud, hideHud };
```
- [ ] **Step 2: Commit**
```bash
git add electron/hud.js
git commit -m "feat: HUD BrowserWindow — loads fox.pxo.at/hud with preload injection"
```
---
### Task 6: tray.js — Menu Bar Icon
**Files:**
- Create: `electron/tray.js`
- [ ] **Step 1: tray.js erstellen**
`electron/tray.js`:
```javascript
const { Tray, Menu, nativeImage } = require('electron');
const path = require('path');
let tray = null;
function createTray({ onShow, onHide, onQuit }) {
const iconPath = path.join(__dirname, '../assets/fox-tray.png');
const icon = nativeImage.createFromPath(iconPath);
icon.setTemplateImage(true);
tray = new Tray(icon);
tray.setToolTip('Fox');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Fox öffnen', accelerator: 'Cmd+Shift+F', click: onShow },
{ label: 'Fox verstecken', click: onHide },
{ type: 'separator' },
{ label: 'Beenden', click: onQuit },
]));
tray.on('click', onShow);
return tray;
}
module.exports = { createTray };
```
- [ ] **Step 2: Commit**
```bash
git add electron/tray.js
git commit -m "feat: tray — menu bar icon with context menu"
```
---
### Task 7: Wakeword — Python + Electron Subprocess
**Files:**
- Create: `python/wakeword.py`
- Create: `electron/wakeword.js`
- [ ] **Step 1: Python-Abhängigkeiten installieren**
```bash
pip install openwakeword pyaudio numpy onnxruntime
```
Expected: alle Pakete installiert ohne Fehler. Falls `pyaudio` auf macOS fehlschlägt:
```bash
brew install portaudio && pip install pyaudio
```
- [ ] **Step 2: python/wakeword.py erstellen**
`python/wakeword.py`:
```python
#!/usr/bin/env python3
import sys
import numpy as np
import pyaudio
from openwakeword.model import Model
CHUNK = 1280 # 80ms bei 16kHz — von openwakeword erwartet
RATE = 16000
THRESHOLD = 0.5
def main():
model = Model(wakeword_models=["hey_jarvis"])
pa = pyaudio.PyAudio()
stream = pa.open(
rate=RATE,
channels=1,
format=pyaudio.paInt16,
input=True,
frames_per_buffer=CHUNK,
)
print("Wakeword aktiv", flush=True)
try:
while True:
data = stream.read(CHUNK, exception_on_overflow=False)
audio = np.frombuffer(data, dtype=np.int16)
prediction = model.predict(audio)
for mdl, score in prediction.items():
if score > THRESHOLD:
print("WAKE", flush=True)
model.reset()
break
except KeyboardInterrupt:
pass
finally:
stream.stop_stream()
stream.close()
pa.terminate()
if __name__ == "__main__":
main()
```
- [ ] **Step 3: Python-Script manuell testen**
```bash
python3 python/wakeword.py
```
Expected: `Wakeword aktiv` — dann "Hey Jarvis" sagen.
Expected: `WAKE` erscheint in der Ausgabe.
Mit `Ctrl+C` beenden.
- [ ] **Step 4: electron/wakeword.js erstellen**
`electron/wakeword.js`:
```javascript
const { spawn } = require('child_process');
const path = require('path');
let wakewordProcess = null;
let running = false;
function startWakeword({ onWake }) {
if (running) return;
running = true;
const scriptPath = path.join(__dirname, '../python/wakeword.py');
wakewordProcess = spawn('python3', [scriptPath], {
stdio: ['pipe', 'pipe', 'pipe'],
});
wakewordProcess.stdout.on('data', (data) => {
if (data.toString().trim() === 'WAKE') {
onWake();
}
});
wakewordProcess.stderr.on('data', (data) => {
console.error('[Wakeword]', data.toString().trim());
});
wakewordProcess.on('close', (code) => {
running = false;
wakewordProcess = null;
if (code !== 0) {
setTimeout(() => startWakeword({ onWake }), 2000);
}
});
}
function stopWakeword() {
if (wakewordProcess) {
wakewordProcess.kill();
wakewordProcess = null;
running = false;
}
}
module.exports = { startWakeword, stopWakeword };
```
- [ ] **Step 5: Commit**
```bash
git add python/wakeword.py electron/wakeword.js
git commit -m "feat: wakeword — OpenWakeWord subprocess with hey_jarvis model"
```
---
### Task 8: main.js — Alles verkabeln
**Files:**
- Create: `electron/main.js`
- [ ] **Step 1: main.js erstellen**
`electron/main.js`:
```javascript
const { app, ipcMain, globalShortcut, nativeTheme } = require('electron');
const { createTray } = require('./tray');
const { createHud, showHud, hideHud } = require('./hud');
const { startWakeword, stopWakeword } = require('./wakeword');
const { executeMacCommand } = require('./mac-agent');
const axios = require('axios');
const FOX_URL = process.env.FOX_URL || 'https://fox.pxo.at';
app.dock?.hide();
if (!app.requestSingleInstanceLock()) {
app.quit();
}
app.whenReady().then(() => {
nativeTheme.themeSource = 'dark';
createHud();
createTray({
onShow: showHud,
onHide: hideHud,
onQuit: () => app.quit(),
});
globalShortcut.register('CommandOrControl+Shift+F', showHud);
startWakeword({ onWake: showHud });
ipcMain.on('hide-hud', () => hideHud());
ipcMain.on('send-message', async (_, text) => {
try {
await axios.post(`${FOX_URL}/fox/message`, { text });
} catch (err) {
console.error('[Fox] send-message fehlgeschlagen:', err.message);
}
});
ipcMain.handle('mac-command', async (_, command) => {
return await executeMacCommand(command);
});
});
app.on('will-quit', () => {
globalShortcut.unregisterAll();
stopWakeword();
});
app.on('window-all-closed', (e) => {
e.preventDefault();
});
```
- [ ] **Step 2: App erstmalig starten**
```bash
npm start
```
Expected:
- Kein Dock-Icon
- Oranges Icon in der Menu Bar
- Terminal zeigt `[Wakeword] Wakeword aktiv`
- [ ] **Step 3: Tray-Klick testen**
Auf das Tray-Icon klicken.
Expected: BrowserWindow öffnet sich und lädt `https://fox.pxo.at/hud`.
- [ ] **Step 4: Tastaturkürzel testen**
`Cmd+Shift+F` drücken (HUD muss vorher geschlossen sein).
Expected: HUD öffnet sich.
- [ ] **Step 5: Wakeword testen**
"Hey Jarvis" sagen.
Expected: HUD öffnet sich automatisch, Terminal zeigt nichts Ungewöhnliches.
- [ ] **Step 6: Beenden via Tray-Menü testen**
Rechtsklick auf Tray → "Beenden".
Expected: App beendet sich sauber, kein hängender Prozess.
- [ ] **Step 7: Commit**
```bash
git add electron/main.js
git commit -m "feat: main.js — wires tray, HUD, wakeword, shortcuts and IPC"
```
---
### Task 9: LaunchAgent
**Files:**
- Create: `launchagent/at.pxo.fox.plist`
- [ ] **Step 1: plist-Vorlage erstellen**
`launchagent/at.pxo.fox.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>at.pxo.fox</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/Fox.app/Contents/MacOS/Fox</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/fox-desktop.log</string>
<key>StandardErrorPath</key>
<string>/tmp/fox-desktop.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>FOX_URL</key>
<string>https://fox.pxo.at</string>
</dict>
</dict>
</plist>
```
Aktivieren (erst nach `electron-builder`-Build und Kopieren nach `/Applications`):
```bash
cp launchagent/at.pxo.fox.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/at.pxo.fox.plist
```
- [ ] **Step 2: Commit**
```bash
git add launchagent/at.pxo.fox.plist
git commit -m "feat: LaunchAgent plist for auto-start on login"
```
---
### Task 10: Laravel HUD Anpassungen dokumentieren
**Files:**
- Create: `docs/hud-bridge-integration.md`
- [ ] **Step 1: Integrations-Dokument erstellen**
`docs/hud-bridge-integration.md`:
```markdown
# Fox HUD — Electron Bridge Integration
Änderungen in `~/fox` (Laravel App). Alle Punkte sind **optional**
ohne `window.foxBridge` (normaler Browser) läuft das HUD unverändert weiter.
## 1. ESC → HUD schließen
In `fox-hud.blade.php` oder dem HUD-JavaScript:
```javascript
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') window.foxBridge?.hideHud();
});
```
## 2. Mac-Aktionen in onAction()
Im bestehenden `onAction()` Handler am Anfang ergänzen:
```javascript
if (payload.action === 'spotify_play') { await window.foxBridge?.macCommand('spotify_play'); return; }
if (payload.action === 'spotify_pause') { await window.foxBridge?.macCommand('spotify_pause'); return; }
if (payload.action === 'spotify_next') { await window.foxBridge?.macCommand('spotify_next'); return; }
if (payload.action === 'spotify_prev') { await window.foxBridge?.macCommand('spotify_prev'); return; }
if (payload.action === 'mail_open') { await window.foxBridge?.macCommand('mail_open'); return; }
if (payload.action === 'calendar_open') { await window.foxBridge?.macCommand('calendar_open'); return; }
if (payload.action === 'notes_open') { await window.foxBridge?.macCommand('notes_open'); return; }
if (payload.action === 'volume_up') { await window.foxBridge?.macCommand('volume_up'); return; }
if (payload.action === 'volume_down') { await window.foxBridge?.macCommand('volume_down'); return; }
if (payload.action === 'screenshot') { await window.foxBridge?.macCommand('screenshot'); return; }
if (payload.action === 'open_url' && payload.url) {
await window.foxBridge?.macCommand('open:' + payload.url);
return;
}
if (payload.action === 'open_app' && payload.app) {
await window.foxBridge?.macCommand('app:' + payload.app);
return;
}
if (payload.action === 'save_desktop' && payload.filename) {
await window.foxBridge?.macCommand('save:' + JSON.stringify({
filename: payload.filename,
content: payload.content || '',
}));
return;
}
```
## 3. Nachrichten senden (optional)
Wenn Nachrichten direkt über Electron statt Livewire gesendet werden sollen:
```javascript
window.foxBridge?.sendMessage(text);
```
## 4. Laravel Route für /fox/message
In `routes/api.php` oder `routes/web.php`:
```php
Route::post('/fox/message', function (\Illuminate\Http\Request $request) {
$text = $request->input('text', '');
if (empty(trim($text))) {
return response()->json(['error' => 'Kein Text'], 400);
}
$userMessage = app(\App\Services\FoxAgent::class)
->recordUserMessage($text, ['source' => 'electron']);
dispatch(fn() => app(\App\Services\FoxAgent::class)
->processUserMessage($userMessage));
return response()->json(['status' => 'ok']);
})->middleware('api');
```
## 5. Electron erkennen
```javascript
const isElectron = typeof window.foxBridge !== 'undefined';
```
```
- [ ] **Step 2: Commit**
```bash
git add docs/hud-bridge-integration.md
git commit -m "docs: HUD bridge integration guide for Laravel app"
```