diff --git a/android/src/services/brainApi.ts b/android/src/services/brainApi.ts index 34ce3e5..2ed5dc5 100644 --- a/android/src/services/brainApi.ts +++ b/android/src/services/brainApi.ts @@ -658,6 +658,10 @@ export const brainApi = { stopProjectVm(projectId: string, name: string): Promise<{ ok: boolean; output: string }> { return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/stop`, { method: 'POST', timeoutMs: 30000 }); }, + /** Screenshot der laufenden VM (Base64-PNG) — VM-Bildschirm ohne Live-VNC. */ + screenshotProjectVm(projectId: string, name: string): Promise<{ ok: boolean; filename: string; base64: string }> { + return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/screenshot`, { method: 'POST', timeoutMs: 30000 }); + }, /** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */ setProjectHidden(projectId: string, hidden: boolean): Promise { diff --git a/android/src/workspace/tiles/DesktopTile.tsx b/android/src/workspace/tiles/DesktopTile.tsx index 32b4863..494d0a0 100644 --- a/android/src/workspace/tiles/DesktopTile.tsx +++ b/android/src/workspace/tiles/DesktopTile.tsx @@ -7,7 +7,7 @@ */ import React, { useCallback, useEffect, useState } from 'react'; -import { ActivityIndicator, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import brainApi, { ProjectVm } from '../../services/brainApi'; import VncTile from './VncTile'; @@ -22,6 +22,8 @@ const DesktopTile: React.FC = ({ projectId, focused }) => { const [err, setErr] = useState(''); const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt const [connected, setConnected] = useState(null); + const [shotBusy, setShotBusy] = useState(''); + const [shot, setShot] = useState<{ name: string; b64: string } | null>(null); const load = useCallback(() => { setLoading(true); setErr(''); @@ -51,6 +53,14 @@ const DesktopTile: React.FC = ({ projectId, focused }) => { .finally(() => setBusy('')); }, [projectId, load]); + const screenshot = useCallback((vm: ProjectVm) => { + setShotBusy(vm.name); setErr(''); + brainApi.screenshotProjectVm(projectId, vm.name) + .then(r => setShot({ name: vm.name, b64: r.base64 })) + .catch(e => setErr(String(e?.message || e))) + .finally(() => setShotBusy('')); + }, [projectId]); + if (!focused) { return ( @@ -110,6 +120,11 @@ const DesktopTile: React.FC = ({ projectId, focused }) => { ) : vm.running ? ( <> + screenshot(vm)} style={[styles.vmBtn, { borderColor: '#8888AA' }]} disabled={shotBusy === vm.name}> + {shotBusy === vm.name + ? + : 📷} + setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}> Verbinden @@ -128,6 +143,20 @@ const DesktopTile: React.FC = ({ projectId, focused }) => { }) )} + + setShot(null)}> + setShot(null)}> + {shot?.name} — Screenshot + {shot && ( + + )} + Tippen zum Schließen + + ); }; @@ -149,8 +178,12 @@ const styles = StyleSheet.create({ vmMeta: { color: '#8888AA', fontSize: 12, fontWeight: '400' }, vmCmd: { color: '#6A9BD0', fontSize: 11, fontFamily: 'monospace', marginTop: 4 }, vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' }, - vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6 }, + vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' }, vmBtnText: { fontSize: 12, fontWeight: '700' }, + shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 }, + shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 }, + shotImg: { width: '100%', height: '78%', backgroundColor: '#000' }, + shotHint: { color: '#8888AA', fontSize: 12, marginTop: 12 }, }); export default DesktopTile; diff --git a/aria-brain/main.py b/aria-brain/main.py index a61d10a..5032005 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -1038,6 +1038,35 @@ def project_vm_stop(project_id: str, name: str): return {"ok": rc == 0, "name": name, "output": (out.strip() or err.strip())[:500]} +@app.post("/projects/{project_id}/vms/{name}/screenshot") +def project_vm_screenshot(project_id: str, name: str): + """Macht einen Screenshot der laufenden VM (aria-vm screenshot → PNG in + /shared/uploads) und liefert ihn als Base64 zurueck. So sieht Stefan den + VM-Bildschirm auch ohne Live-VNC (genau wie ARIA es beim Testen macht).""" + import base64 + import time as _t + rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30) + if rc != 0: + raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}") + path = "" + for line in out.splitlines(): + if line.startswith("screenshot="): + path = line.split("=", 1)[1].strip() + if not path: + raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}") + local = os.path.join("/shared/uploads", os.path.basename(path)) + for _ in range(10): # kurze Bind-Mount-Latenz abfangen + if os.path.isfile(local): + break + _t.sleep(0.2) + if not os.path.isfile(local): + raise HTTPException(status_code=500, detail=f"Screenshot-Datei nicht gefunden: {local}") + with open(local, "rb") as f: + data = f.read() + return {"ok": True, "name": name, "filename": os.path.basename(local), + "base64": base64.b64encode(data).decode("ascii")} + + @app.get("/conversation/stats") def conversation_stats(): return conversation().stats()