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

22 KiB
Raw Blame History

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:

{
  "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
mkdir -p electron python assets __tests__ launchagent docs

Expected: kein Fehler.

  • Step 4: Dependencies installieren
npm install

Expected: node_modules/ wird erstellt, keine Fehler.

  • Step 5: Commit
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

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 erstelltassets/fox-tray.png existiert.

  • Step 2: Commit
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:

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
npm test

Expected: Cannot find module '../electron/mac-agent'

  • Step 3: mac-agent.js implementieren

electron/mac-agent.js:

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
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
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:

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
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:

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
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:

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
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

pip install openwakeword pyaudio numpy onnxruntime

Expected: alle Pakete installiert ohne Fehler. Falls pyaudio auf macOS fehlschlägt:

brew install portaudio && pip install pyaudio
  • Step 2: python/wakeword.py erstellen

python/wakeword.py:

#!/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
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:

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
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:

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
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
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 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):

cp launchagent/at.pxo.fox.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/at.pxo.fox.plist
  • Step 2: Commit
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:

# 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:

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:

window.foxBridge?.sendMessage(text);

4. Laravel Route für /fox/message

In routes/api.php oder routes/web.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

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"