From 239f1094f9b5fccdb9a2e7cdfcb2b25a97bcff74 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 20 Jul 2026 23:55:47 +0200 Subject: [PATCH] feat(vm): Screenshot pro VM im Desktop-Panel (VM sehen ohne Live-VNC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solange das Live-VNC-Bild noch hakt, ist ein Standbild der pragmatische Weg die VM zu sehen β€” genau wie ARIA es beim basic_os-Test gemacht hat. - Brain: POST /projects//vms//screenshot β†’ aria-vm screenshot (PNG in /shared/uploads) β†’ als Base64 zurueck. End-to-end auf dem Host validiert (Brain-Container sieht die Datei unter /shared/uploads). - App: πŸ“·-Knopf pro laufender VM im DesktopTile β†’ zeigt den Screenshot im Modal. py/tsc clean. Co-Authored-By: Claude Opus 4.8 --- android/src/services/brainApi.ts | 4 +++ android/src/workspace/tiles/DesktopTile.tsx | 37 +++++++++++++++++++-- aria-brain/main.py | 29 ++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) 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()