Compare commits

..
14 Commits
Author SHA1 Message Date
duffyduck ef826d1ed1 release: bump version to 0.2.2.7 2026-07-21 01:01:54 +02:00
duffyduckandClaude Opus 4.8 933836f0a6 fix(app): kein 404 bei Dateien/Desktop im Hauptchat (leere project_id)
Im Hauptchat (projectId='') riefen FilesTile/DesktopTile /projects//files bzw.
/vms auf → Brain 404. Beide guarden jetzt leere projectId: kein Fetch, klare
Meldung "Kein aktives Projekt — wechsle in ein Projekt".

tsc clean. App-only, Deploy: APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:59:50 +02:00
duffyduck e04d8f360b release: bump version to 0.2.2.6 2026-07-21 00:54:02 +02:00
duffyduckandClaude Opus 4.8 4685632294 feat(vnc): echtes Vollbild beim Verbinden (randlos ueber das ganze Display)
Der ⤢-Knopf war nur der Fit/1:1-Umschalter (Zoom) INNERHALB des eingerahmten
Streifens (Header+Dock drumrum) — sah deshalb wie Zoom aus, fuellte nie das
Display. Jetzt oeffnet "Verbinden" die VM in einem Fullscreen-Modal: randlos
ueber alles (Header + Dock weg), scaleViewport passt den VM-Screen sauber ein,
Landscape erlaubt. Buttons schweben drueber (‹ VMs links, ⌨/Strg+Alt+Entf/⤢
rechts, unter der Statusleiste). Android-Back verlaesst Vollbild.

tsc clean. App-only, Deploy: APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:51:59 +02:00
duffyduck 769025c41b release: bump version to 0.2.2.5 2026-07-21 00:47:46 +02:00
duffyduckandClaude Opus 4.8 2005e9b85e fix(vm): Screenshot ohne /root-Rechte + VNC-Tastatur robuster
Screenshot: aria-vm lief als User 'aria', SHOT_DIR war aber /root/... →
"mkdir: cannot create directory /root". Jetzt schreibt aria-vm das PNG ins
VM-Verzeichnis (aria-schreibbar) und der Brain holt es per SSH (base64,
_ssh_host) — unabhaengig von Volume-Rechten. End-to-end validiert (gueltiges
PNG). Wird weiter ins Projekt (screenshots/) kopiert.

VNC-Tastatur: verstecktes Input-Feld war off-screen (opacity:0, left:-1000px) →
Android oeffnete die Tastatur oft nicht / lieferte keine Events. Jetzt on-screen
(bottom, 1px, opacity:0, pointer-events:none) + beforeinput als primaerer
Handler (Android-robust) mit input-Fallback. Strg+Alt+Entf ging schon.

py/bash/tsc clean. aria-vm bereits live. Deploy: brain rebuild + APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:45:41 +02:00
duffyduckandClaude Opus 4.8 25abd220ad fix(vnc): Bridge nutzt aria-net-Gateway statt host.docker.internal (VNC refused)
Kern des "Verbinde..."-Hangs: host.docker.internal loeste zu 172.17.0.1 (docker0)
auf, QEMUs VNC ist aber ans aria-net-Gateway 192.168.64.1 gebunden (dorthin
bindet der Brain) → Connection refused. Die Bridge ermittelt den VNC-Host jetzt
selbst per _docker_gateway() (/proc/net/route) = dasselbe Gateway wie der Brain.
ARIA_VNC_HOST-Env hat weiter Vorrang.

Live belegt: Bridge->192.168.64.1:5901 liefert RFB-Handshake, ->host.docker.
internal:5901 (172.17.0.1) = refused.

py_compile clean. Deploy: bridge rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:35:26 +02:00
duffyduck 3c6bf0783e release: bump version to 0.2.2.4 2026-07-21 00:25:19 +02:00
duffyduckandClaude Opus 4.8 2832ab3dc9 fix(vm): global eindeutige VNC-Displays (kein Doppel-Binding) + Gateway per-boot
- project_vms.add_vm: VNC-Display wird GLOBAL eindeutig vergeben (ueber alle
  Projekte). vnc_display<=0 oder belegtes Display → naechstes freies. Updates
  behalten ihr Display. Verhindert Port-Konflikt (5901) wenn mehrere VMs laufen.
  Getestet: vm1..3 → 1,2,3; Wunsch 2 (belegt) → 4; Update behaelt Display.
- vm_register-Tool + Seed-Regel: vnc_display weglassen, wird auto-vergeben.
- Gateway-IP: der Brain liest sie aus /proc/net/route bei JEDEM Boot frisch
  (Docker-IP-Aenderung nach Netz-Neuaufbau wird abgefangen); boot-Antwort
  enthaelt vnc_bind zur Transparenz.

py_compile clean. Brain-only, Deploy: brain rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:23:23 +02:00
duffyduckandClaude Opus 4.8 d38d62ba21 fix(vnc): Bridge erreicht QEMU-VNC (Kern-Bug) + Screenshots ins Projekt
Live-VNC rendert nie, weil die Bridge die VM nie erreichte: QEMU band 127.0.0.1
(Host-Loopback) und host.docker.internal resolved in der Bridge gar nicht.
- aria-vm: --vnc-bind <ip> (Default 127.0.0.1 / env ARIA_VM_VNC_BIND).
- Brain: _docker_gateway() (aus /proc/net/route) → boot bindet VNC an die
  Docker-Gateway-IP (container-erreichbar, NICHT im LAN/Internet).
- docker-compose: bridge bekommt extra_hosts host.docker.internal:host-gateway.
- Screenshot-Endpoint kopiert das PNG zusaetzlich nach
  /shared/projects/<pid>/screenshots/ → erscheint im Dateien-Panel.

End-to-end auf dem Host validiert: Bridge liest den RFB-Handshake
(b'RFB 003.008\n') von der Gateway-gebundenen VNC. aria-vm bereits live.
py/bash clean. Deploy: brain rebuild + bridge NEU (extra_hosts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:20:21 +02:00
duffyduckandClaude Opus 4.8 5890c17ec0 feat(app): Dateien-Panel im Dock (zwischen Chat und Code)
Neues 📁-Panel listet ALLE Projektdateien (/shared/projects/<id>/) — auch
erzeugte Bilder, nicht nur Code; dieselben, die in der Projektliste als 📄
gezaehlt werden. Bild antippen → Vollbild-Vorschau; Textdatei → Text-Vorschau.
- Brain: /projects/<id>/file?binary=1 → Base64 + MIME (fuer Bilder, max 8 MB).
- App: brainApi.readProjectFileBinary; layout 'files'-Tile; Dock-Reihenfolge
  Chat · Dateien · Code · Desktop; FilesTile mit Bild-/Text-Modal.

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:09:34 +02:00
duffyduckandClaude Opus 4.8 239f1094f9 feat(vm): Screenshot pro VM im Desktop-Panel (VM sehen ohne Live-VNC)
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/<pid>/vms/<name>/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 <noreply@anthropic.com>
2026-07-20 23:55:47 +02:00
duffyduckandClaude Opus 4.8 055db7c059 feat(vm): Medien pro VM (disk/floppy/iso) + ARIA-Urteilswissen
vm_register + Registry + boot fassen jetzt das ECHTE Boot-Medium (disk/floppy/
iso) — Eintrag, Startbefehl und App-Start spiegeln die reale Config. Leer =
aria-vm erkennt disk.qcow2/floppy.img/cdrom.iso selbst.
- project_vms.add_vm: floppy/disk-Felder.
- main.py: VmAddBody + _vm_boot_args (baut --disk/--floppy/--iso), boot-Endpoint
  + boot_cmd nutzen sie.
- agent.py: vm_register-Tool bekommt disk/floppy/iso.
- Seed-Regel: URTEIL — VM nur wenn sinnvoll; Medium aus der Situation (Festplatte
  fuer DOS-Spiele, Diskette fuer OS-Dev, ISO fuer Installer); Architektur zum Task
  (ARM=aarch64, emuliert/langsam ok); Aenderungs-Zyklus stop→aendern→boot.

py_compile clean. aria-vm schon live auf dem Host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:51:27 +02:00
duffyduckandClaude Opus 4.8 2a8cbc6c15 fix(vm): aria-vm boot flexibel (Diskette/ISO ohne Disk) + ARIA-Grundwissen
Start aus der App scheiterte: aria-vm boot verlangte zwingend disk.qcow2 —
basic_os bootet aber von floppy.img/ISO ohne Festplatte (OS-Bau). Jetzt:
- aria-vm boot: Disk optional; --floppy (-fda), --iso, --disk; Auto-Erkennung
  von disk.qcow2/floppy.img/cdrom.iso im VM-Ordner; Boot-Reihenfolge automatisch
  (ISO→d, nur Diskette→a, sonst c); braucht nur >=1 Boot-Medium.
- aria-vm create: groesse='none' → VM ohne Disk.
- Seed-Regel (Grundwissen) neu: volle aria-vm-Optionen inkl. disk-los, PFLICHT
  vm_register nach Bau (sonst leere Desktop-Liste), Dateien in /shared/projects,
  VNC komplett durch RVS. So weiss ARIA selbst was zu tun ist.

Live getestet auf dem Host: boot basicos (floppy-Autodetect) → laeuft :5901.
aria-vm bereits auf den Host gepusht. bash -n / py_compile OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:48:22 +02:00
17 changed files with 524 additions and 105 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20203
versionName "0.2.2.3"
versionCode 20207
versionName "0.2.2.7"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.2.3",
"version": "0.2.2.7",
"private": true,
"scripts": {
"android": "react-native run-android",
+10 -1
View File
@@ -637,11 +637,16 @@ export const brainApi = {
return _send(`/projects/${encodeURIComponent(projectId)}/files`);
},
/** Inhalt einer Projekt-Datei laden. */
/** Inhalt einer Projekt-Datei laden (Text). */
readProjectFile(projectId: string, path: string): Promise<{ projectId: string; path: string; content: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`);
},
/** Binaere Projekt-Datei (z.B. Bild) als Base64 + MIME laden. */
readProjectFileBinary(projectId: string, path: string): Promise<{ path: string; mime: string; base64: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/file?binary=1&path=${encodeURIComponent(path)}`, { timeoutMs: 30000 });
},
// ── QEMU-VMs pro Projekt ─────────────────────────────────────────
listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> {
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { timeoutMs: 20000 });
@@ -658,6 +663,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<Project> {
+2
View File
@@ -15,6 +15,7 @@ import { TileId } from './layout';
import { useWorkspaceLayout } from './useWorkspaceLayout';
import WorkspaceDock from './WorkspaceDock';
import ChatTile from './tiles/ChatTile';
import FilesTile from './tiles/FilesTile';
import CodeEditorTile from './tiles/CodeEditorTile';
import DesktopTile from './tiles/DesktopTile';
@@ -57,6 +58,7 @@ const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
const render = (id: TileId) => {
switch (id) {
case 'chat': return <ChatTile />;
case 'files': return <FilesTile projectId={projectId} focused={active === 'files'} />;
case 'editor': return <CodeEditorTile projectId={projectId} />;
case 'vnc': return <DesktopTile projectId={projectId} focused={active === 'vnc'} />;
default: return null;
+1 -1
View File
@@ -17,7 +17,7 @@ import ChatScreen from '../screens/ChatScreen';
import { TileId } from './layout';
import WorkspaceDeck from './WorkspaceDeck';
const COCKPIT_PANELS: TileId[] = ['chat', 'editor', 'vnc'];
const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc'];
const WorkspaceScreen: React.FC = () => {
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
+21 -4
View File
@@ -77,11 +77,17 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
var msg=document.getElementById('msg');
// Verstecktes Eingabefeld → Software-Tastatur des Handys tippt in die VM.
// (Fast) unsichtbares Eingabefeld → Software-Tastatur des Handys tippt in die
// VM. WICHTIG: on-screen (nicht off-screen), sonst oeffnet Android die Tastatur
// oft nicht bzw. liefert keine Events. opacity:0 + pointer-events:none = sichtbar
// fuer den Fokus, aber unsichtbar und klaut keine VM-Touches. font-size:16px
// verhindert Auto-Zoom.
var kbd=document.createElement('input');
kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off');
kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false;
kbd.style.cssText='position:absolute;left:-1000px;top:0;width:1px;height:1px;opacity:0;';
kbd.style.cssText='position:fixed;bottom:0;left:0;width:100%;height:1px;opacity:0;'
+'border:0;padding:0;margin:0;background:transparent;color:transparent;'
+'caret-color:transparent;font-size:16px;pointer-events:none;';
document.body.appendChild(kbd);
var SPECIAL={Enter:0xff0d,Backspace:0xff08,Tab:0xff09,Escape:0xff1b,Delete:0xffff,
ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54,Home:0xff50,End:0xff57};
@@ -101,16 +107,27 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
// Tasten aus dem versteckten Feld an die VM schicken.
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
// Sondertasten (feuern zuverlaessig als keydown).
kbd.addEventListener('keydown', function(e){
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
});
// Druckbare Zeichen + Editieren: beforeinput ist auf Android robuster als
// ein input-Diff. preventDefault haelt das Feld leer → kein Doppel-Senden.
kbd.addEventListener('beforeinput', function(e){
var t=e.inputType||'';
if(t==='insertText' && e.data){ for(var i=0;i<e.data.length;i++) tap(e.data.charCodeAt(i)); e.preventDefault(); }
else if(t.indexOf('insertLineBreak')>=0 || t.indexOf('insertParagraph')>=0){ tap(0xff0d); e.preventDefault(); }
else if(t.indexOf('deleteContentBackward')>=0){ tap(0xff08); e.preventDefault(); }
kbd.value='';
});
// Fallback, falls beforeinput nicht unterstuetzt wird.
kbd.addEventListener('input', function(){
var v=kbd.value; for(var i=0;i<v.length;i++){ tap(v.charCodeAt(i)); } kbd.value='';
if(kbd.value){ for(var i=0;i<kbd.value.length;i++){ tap(kbd.value.charCodeAt(i)); } kbd.value=''; }
});
// Steuerungs-API fuer die App (per injectJavaScript).
window.ariaVncCtl = {
focusKeyboard: function(){ try{ kbd.focus(); }catch(_){} },
focusKeyboard: function(){ try{ kbd.value=''; kbd.focus(); }catch(_){} },
blurKeyboard: function(){ try{ kbd.blur(); }catch(_){} },
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
+2 -1
View File
@@ -2,12 +2,13 @@
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
*/
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
export type TileId = 'chat' | 'files' | 'editor' | 'vnc' | 'preview';
export interface TileDef { id: TileId; title: string; icon: string }
export const TILE_META: Record<TileId, TileDef> = {
chat: { id: 'chat', title: 'Chat', icon: '💬' },
files: { id: 'files', title: 'Dateien', icon: '📁' },
editor: { id: 'editor', title: 'Code', icon: '📝' },
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
+53 -17
View File
@@ -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,8 +22,11 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
const [err, setErr] = useState('');
const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt
const [connected, setConnected] = useState<ProjectVm | null>(null);
const [shotBusy, setShotBusy] = useState('');
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
const load = useCallback(() => {
if (!projectId) { setVms([]); setErr(''); setLoading(false); return; }
setLoading(true); setErr('');
brainApi.listProjectVms(projectId)
.then(r => setVms(r.vms || []))
@@ -51,6 +54,14 @@ const DesktopTile: React.FC<Props> = ({ 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 (
<View style={styles.placeholder}>
@@ -61,21 +72,6 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
);
}
// Verbunden → noVNC-Ansicht der VM + Zurück-Leiste.
if (connected) {
return (
<View style={styles.container}>
<View style={styles.bar}>
<TouchableOpacity onPress={() => setConnected(null)} style={styles.barBtn}>
<Text style={styles.barBtnText}> VMs</Text>
</TouchableOpacity>
<Text style={styles.barTitle} numberOfLines={1}>{connected.name} · :{connected.vnc_display}</Text>
</View>
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.bar}>
@@ -87,6 +83,8 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
) : err ? (
<Text style={styles.err}>{err}</Text>
) : !projectId ? (
<Text style={styles.empty}>Kein aktives Projekt wechsle in ein Projekt für dessen VMs.</Text>
) : vms.length === 0 ? (
<Text style={styles.empty}>
Noch keine VM in diesem Projekt.{'\n'}
@@ -110,6 +108,11 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
<ActivityIndicator color="#0096FF" />
) : vm.running ? (
<>
<TouchableOpacity onPress={() => screenshot(vm)} style={[styles.vmBtn, { borderColor: '#8888AA' }]} disabled={shotBusy === vm.name}>
{shotBusy === vm.name
? <ActivityIndicator color="#8888AA" size="small" />
: <Text style={[styles.vmBtnText, { color: '#C8C8E0' }]}>📷</Text>}
</TouchableOpacity>
<TouchableOpacity onPress={() => setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}>
<Text style={[styles.vmBtnText, { color: '#0096FF' }]}>Verbinden</Text>
</TouchableOpacity>
@@ -128,6 +131,32 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
})
)}
</ScrollView>
<Modal visible={!!shot} transparent animationType="fade" onRequestClose={() => setShot(null)}>
<TouchableOpacity style={styles.shotOverlay} activeOpacity={1} onPress={() => setShot(null)}>
<Text style={styles.shotTitle}>{shot?.name} — Screenshot</Text>
{shot && (
<Image
source={{ uri: `data:image/png;base64,${shot.b64}` }}
style={styles.shotImg}
resizeMode="contain"
/>
)}
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
</TouchableOpacity>
</Modal>
{/* Vollbild-VNC — randlos ueber das ganze Display (Header + Dock weg). */}
{connected && (
<Modal visible animationType="slide" onRequestClose={() => setConnected(null)} supportedOrientations={['portrait', 'landscape']}>
<View style={styles.fs}>
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
<TouchableOpacity style={styles.fsBack} onPress={() => setConnected(null)} activeOpacity={0.8}>
<Text style={styles.fsBackText}> VMs</Text>
</TouchableOpacity>
</View>
</Modal>
)}
</View>
);
};
@@ -149,8 +178,15 @@ 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' },
fs: { flex: 1, backgroundColor: '#000000' },
fsBack: { position: 'absolute', top: 34, left: 10, backgroundColor: 'rgba(18,18,42,0.9)', borderColor: '#2A2A3E', borderWidth: 1, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 7 },
fsBackText: { color: '#0096FF', fontSize: 14, 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;
+149
View File
@@ -0,0 +1,149 @@
/**
* FilesTile — Datei-Browser eines Projekts (/shared/projects/<id>/).
*
* Listet ALLE Dateien (nicht nur Code): erzeugte Bilder, Logs, Assets … — die
* gleichen, die in der Projektliste als 📄 gezaehlt werden. Tippen auf ein Bild
* zeigt es; tippen auf eine Textdatei zeigt eine Vorschau.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import brainApi from '../../services/brainApi';
interface Props {
projectId: string;
focused: boolean;
}
interface FileEntry { path: string; size: number }
const IMG_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'];
function ext(path: string): string { return (path.split('.').pop() || '').toLowerCase(); }
function isImage(path: string): boolean { return IMG_EXT.includes(ext(path)); }
function iconFor(path: string): string {
const e = ext(path);
if (isImage(path)) return '🖼️';
if (['md', 'txt', 'readme'].includes(e)) return '📄';
if (['asm', 's', 'c', 'h', 'cpp', 'py', 'js', 'ts', 'sh', 'go', 'rs'].includes(e)) return '📝';
if (['zip', 'tar', 'gz', 'img', 'iso', 'qcow2'].includes(e)) return '📦';
return '📄';
}
function humanSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const [preview, setPreview] = useState<{ path: string; kind: 'image' | 'text'; data: string } | null>(null);
const [previewBusy, setPreviewBusy] = useState('');
const load = useCallback(() => {
if (!projectId) { setFiles([]); setErr(''); setLoading(false); return; }
setLoading(true); setErr('');
brainApi.listProjectFiles(projectId)
.then(r => setFiles((r.files || []).slice().sort((a, b) => a.path.localeCompare(b.path))))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
}, [projectId]);
useEffect(() => { if (focused) load(); }, [focused, projectId, load]);
const open = useCallback((f: FileEntry) => {
setPreviewBusy(f.path); setErr('');
if (isImage(f.path)) {
brainApi.readProjectFileBinary(projectId, f.path)
.then(r => setPreview({ path: f.path, kind: 'image', data: `data:${r.mime};base64,${r.base64}` }))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setPreviewBusy(''));
} else {
brainApi.readProjectFile(projectId, f.path)
.then(r => setPreview({ path: f.path, kind: 'text', data: r.content ?? '' }))
.catch(e => setErr(String(e?.message || e)))
.finally(() => setPreviewBusy(''));
}
}, [projectId]);
if (!focused) {
return (
<View style={styles.placeholder}>
<Text style={styles.icon}>📁</Text>
<Text style={styles.text}>Dateien</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.bar}>
<Text style={styles.barTitle}>Dateien{files.length ? ` (${files.length})` : ''}</Text>
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}></Text></TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 8 }}>
{loading && files.length === 0 ? (
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
) : err ? (
<Text style={styles.err}>{err}</Text>
) : files.length === 0 ? (
<Text style={styles.empty}>{!projectId ? 'Kein aktives Projekt — wechsle in ein Projekt für dessen Dateien.' : 'Noch keine Dateien in diesem Projekt.'}</Text>
) : (
files.map(f => (
<TouchableOpacity key={f.path} onPress={() => open(f)} style={styles.row} disabled={previewBusy === f.path}>
<Text style={styles.rowIcon}>{iconFor(f.path)}</Text>
<Text style={styles.rowName} numberOfLines={1}>{f.path}</Text>
{previewBusy === f.path
? <ActivityIndicator color="#8888AA" size="small" />
: <Text style={styles.rowSize}>{humanSize(f.size)}</Text>}
</TouchableOpacity>
))
)}
</ScrollView>
<Modal visible={!!preview} transparent animationType="fade" onRequestClose={() => setPreview(null)}>
<View style={styles.pvOverlay}>
<View style={styles.pvBar}>
<Text style={styles.pvTitle} numberOfLines={1}>{preview?.path}</Text>
<TouchableOpacity onPress={() => setPreview(null)}><Text style={styles.pvClose}></Text></TouchableOpacity>
</View>
{preview?.kind === 'image' ? (
<Image source={{ uri: preview.data }} style={styles.pvImg} resizeMode="contain" />
) : (
<ScrollView style={styles.pvTextWrap} horizontal>
<ScrollView><Text style={styles.pvText}>{preview?.data}</Text></ScrollView>
</ScrollView>
)}
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0D0D1A' },
placeholder: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
icon: { fontSize: 56, marginBottom: 10 },
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
empty: { color: '#8888AA', fontSize: 13, textAlign: 'center', marginTop: 24 },
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16, paddingHorizontal: 8 },
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 8, borderBottomColor: '#161628', borderBottomWidth: 1, gap: 10 },
rowIcon: { fontSize: 18 },
rowName: { color: '#E0E0F0', fontSize: 13, flex: 1 },
rowSize: { color: '#555570', fontSize: 11 },
pvOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.94)' },
pvBar: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 10 },
pvTitle: { color: '#E0E0F0', fontSize: 13, fontWeight: '700', flex: 1 },
pvClose: { color: '#E0E0F0', fontSize: 20, paddingHorizontal: 6 },
pvImg: { flex: 1, width: '100%' },
pvTextWrap: { flex: 1, padding: 12 },
pvText: { color: '#C8C8E0', fontSize: 12, fontFamily: 'monospace' },
});
export default FilesTile;
+1 -1
View File
@@ -119,7 +119,7 @@ const styles = StyleSheet.create({
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
ctlBar: {
position: 'absolute',
top: 8,
top: 34,
right: 8,
flexDirection: 'row',
gap: 6,
+13 -6
View File
@@ -1170,16 +1170,21 @@ META_TOOLS = [
"Traegt eine QEMU-VM in die VM-Liste des AKTUELLEN Projekts ein, "
"damit sie in Stefans Desktop-Panel (Cockpit) erscheint und er sie "
"starten/stoppen/verbinden kann. Rufe das auf, NACHDEM Du mit aria-vm "
"eine VM gebaut/gebootet hast (z.B. bei einem OS-Bau-Projekt). "
"vnc_display bestimmt den VNC-Port (Port = 5900+display)."
"eine VM gebaut/gebootet hast — mit den Medien, die zum Task passen: "
"disk (Festplatte, z.B. DOS-Spiele), floppy (Diskette, OS-Dev), iso "
"(Installer). Leer lassen = aria-vm erkennt disk.qcow2/floppy.img/"
"cdrom.iso im VM-Ordner selbst. vnc_display → Port 5900+display "
"(mehrere VMs = verschiedene Displays)."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "VM-Name wie bei aria-vm (a-z0-9_-)."},
"arch": {"type": "string", "description": "z.B. i386, x86_64, aarch64, mips."},
"iso": {"type": "string", "description": "optional: Boot-ISO-Pfad auf dem Host."},
"vnc_display": {"type": "integer", "description": "VNC-Display (Default 1 → Port 5901)."},
"arch": {"type": "string", "description": "z.B. i386, x86_64, aarch64, mips (Architektur zum Task waehlen)."},
"disk": {"type": "string", "description": "optional: qcow2-Pfad (Festplatte)."},
"floppy": {"type": "string", "description": "optional: Disketten-Image-Pfad (-fda)."},
"iso": {"type": "string", "description": "optional: Boot-ISO-Pfad."},
"vnc_display": {"type": "integer", "description": "meist WEGLASSEN — das VNC-Display wird global eindeutig auto-vergeben (kein Port-Konflikt bei mehreren VMs). Nur setzen wenn Du ein bestimmtes willst."},
"mem": {"type": "integer", "description": "RAM in MB (Default 1024)."},
},
"required": ["name", "arch"],
@@ -2839,7 +2844,9 @@ class Agent:
pid, (arguments.get("name") or "").strip(),
(arguments.get("arch") or "i386").strip(),
(arguments.get("iso") or "").strip(),
int(arguments.get("vnc_display") or 1),
(arguments.get("floppy") or "").strip(),
(arguments.get("disk") or "").strip(),
int(arguments.get("vnc_display") or 0),
int(arguments.get("mem") or 1024),
)
except (ValueError, TypeError) as exc:
+105 -19
View File
@@ -912,13 +912,24 @@ def project_files(project_id: str):
@app.get("/projects/{project_id}/file")
def project_file(project_id: str, path: str):
def project_file(project_id: str, path: str, binary: bool = False):
base = _project_dir(project_id)
target = os.path.realpath(os.path.join(base, path))
if target != base and not target.startswith(base + os.sep):
raise HTTPException(status_code=400, detail="Pfad ausserhalb des Projekts")
if not os.path.isfile(target):
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
# Binaer (z.B. Bilder) → Base64. Grosszuegigeres Limit als beim Text-Editor.
if binary:
import base64
import mimetypes
if os.path.getsize(target) > 8 * 1024 * 1024:
raise HTTPException(status_code=413, detail="Datei zu gross (max 8 MB)")
with open(target, "rb") as f:
data = f.read()
mime, _ = mimetypes.guess_type(target)
return {"projectId": project_id, "path": path, "mime": mime or "application/octet-stream",
"base64": base64.b64encode(data).decode("ascii")}
if os.path.getsize(target) > _PROJECT_FILE_MAX:
raise HTTPException(status_code=413, detail="Datei zu gross fuer den Editor")
try:
@@ -935,17 +946,38 @@ def project_file(project_id: str, path: str):
_ARIA_VM_HOST = os.environ.get("ARIA_VM_SSH_HOST", "aria-wohnung")
def _ssh_aria_vm(*args: str, timeout: int = 25):
import subprocess
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
_ARIA_VM_HOST, "aria-vm", *[str(a) for a in args]]
def _docker_gateway() -> str:
"""Docker-Gateway-IP (= Host-IP auf dem Container-Netz), an die QEMU sein VNC
binden soll: von der Bridge erreichbar, aber NICHT im LAN/Internet. Aus
/proc/net/route (Default-Route), kein `ip`-Tool noetig."""
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
import socket as _sock
import struct as _struct
with open("/proc/net/route") as f:
for line in f.readlines()[1:]:
fields = line.strip().split()
if len(fields) >= 3 and fields[1] == "00000000" and int(fields[3], 16) & 2:
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
except Exception:
pass
return ""
def _ssh_host(*cmd: str, timeout: int = 25):
import subprocess
full = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
_ARIA_VM_HOST, *[str(c) for c in cmd]]
try:
r = subprocess.run(full, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as exc:
return 1, "", str(exc)
def _ssh_aria_vm(*args: str, timeout: int = 25):
return _ssh_host("aria-vm", *args, timeout=timeout)
def _vm_running_names() -> set:
rc, out, _err = _ssh_aria_vm("list", timeout=15)
names = set()
@@ -961,20 +993,30 @@ class VmAddBody(BaseModel):
name: str
arch: str = "i386"
iso: str = ""
floppy: str = ""
disk: str = ""
vnc_display: int = 1
mem: int = 1024
create_disk: bool = False
size: str = "10G"
def _vm_boot_args(v: dict) -> list:
args = ["boot", v.get("name", "?"),
"--vnc-display", str(v.get("vnc_display", 1)),
"--mem", str(v.get("mem", 1024))]
if v.get("disk"):
args += ["--disk", v["disk"]]
if v.get("floppy"):
args += ["--floppy", v["floppy"]]
if v.get("iso"):
args += ["--iso", v["iso"]]
return args
def _vm_boot_cmd(v: dict) -> str:
"""Lesbarer Start-Befehl (aria-vm) als 'Wert' hinter dem VM-Eintrag."""
parts = ["aria-vm", "boot", v.get("name", "?"),
"--vnc-display", str(v.get("vnc_display", 1)),
"--mem", str(v.get("mem", 1024))]
if v.get("iso"):
parts += ["--iso", v["iso"]]
return " ".join(parts)
return "aria-vm " + " ".join(_vm_boot_args(v))
@app.get("/projects/{project_id}/vms")
@@ -992,7 +1034,7 @@ def project_vms_list(project_id: str):
def project_vm_add(project_id: str, body: VmAddBody):
try:
vm = project_vms_mod.add_vm(project_id, body.name, body.arch, body.iso,
body.vnc_display, body.mem)
body.floppy, body.disk, body.vnc_display, body.mem)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if body.create_disk:
@@ -1017,13 +1059,15 @@ def project_vm_boot(project_id: str, name: str):
vm = project_vms_mod.get_vm(project_id, name)
if not vm:
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht gefunden")
args = ["boot", name, "--vnc-display", str(vm.get("vnc_display", 1)),
"--mem", str(vm.get("mem", 1024))]
if vm.get("iso"):
args += ["--iso", vm["iso"]]
rc, out, err = _ssh_aria_vm(*args, timeout=40)
# VNC an die Docker-Gateway-IP binden, damit die Bridge den Stream tunneln
# kann (Loopback ist von Containern nicht erreichbar). NICHT im LAN sichtbar.
boot_args = _vm_boot_args(vm)
gw = _docker_gateway()
if gw:
boot_args += ["--vnc-bind", gw]
rc, out, err = _ssh_aria_vm(*boot_args, timeout=40)
return {"ok": rc == 0, "name": name, "vnc_port": 5900 + int(vm.get("vnc_display", 1)),
"output": (out.strip() or err.strip())[:500]}
"vnc_bind": gw or "127.0.0.1", "output": (out.strip() or err.strip())[:500]}
@app.post("/projects/{project_id}/vms/{name}/stop")
@@ -1032,6 +1076,48 @@ 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 und liefert ihn als Base64.
aria-vm schreibt das PNG ins VM-Verzeichnis (dem aria-User gehoerend — nicht
ins /root-Shared-Volume, wo der aria-User keinen Zugriff hat). Der Brain holt
die Datei danach per SSH (base64) — funktioniert unabhaengig von Volume-
Rechten. Zusaetzlich wird das PNG ins Projekt kopiert (Dateien-Panel)."""
import base64
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]}")
# PNG per SSH als Base64 holen (kein Shared-Volume noetig).
rc2, b64, err2 = _ssh_host("base64", "-w0", path, timeout=20)
if rc2 != 0 or not b64.strip():
raise HTTPException(status_code=500, detail=f"Screenshot konnte nicht gelesen werden: {(err2 or 'leer').strip()[:200]}")
b64 = b64.strip()
try:
data = base64.b64decode(b64)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Base64 ungueltig: {exc}")
fname = os.path.basename(path)
# Ins Projekt kopieren → taucht im Dateien-Panel auf.
proj_rel = ""
try:
shots_dir = os.path.join(_project_dir(project_id), "screenshots")
os.makedirs(shots_dir, exist_ok=True)
with open(os.path.join(shots_dir, fname), "wb") as f:
f.write(data)
proj_rel = "screenshots/" + fname
except Exception:
proj_rel = ""
return {"ok": True, "name": name, "filename": fname,
"projectPath": proj_rel, "base64": b64}
@app.get("/conversation/stats")
def conversation_stats():
return conversation().stats()
+41 -9
View File
@@ -51,8 +51,30 @@ def get_vm(project_id: str, name: str) -> Optional[dict]:
return None
def _used_displays(data: dict, exclude: object = None) -> set:
"""Alle VNC-Displays, die ueber ALLE Projekte belegt sind (exclude = eine
VM-Dict-Instanz, die ignoriert wird — fuer Updates)."""
used = set()
for lst in data.values():
for v in lst:
if v is exclude:
continue
try:
used.add(int(v.get("vnc_display", 1)))
except (TypeError, ValueError):
pass
return used
def add_vm(project_id: str, name: str, arch: str, iso: str = "",
vnc_display: int = 1, mem: int = 1024) -> dict:
floppy: str = "", disk: str = "",
vnc_display: int = 0, mem: int = 1024) -> dict:
"""Registriert/aktualisiert eine VM. Medien (disk/floppy/iso) optional —
leer = aria-vm erkennt disk.qcow2/floppy.img/cdrom.iso im VM-Ordner selbst.
Das VNC-Display wird GLOBAL eindeutig vergeben (ueber alle Projekte), damit
mehrere laufende VMs nicht denselben Port doppelt binden. vnc_display<=0 oder
ein bereits belegtes Display → automatisch das naechste freie."""
if not NAME_RE.match(name or ""):
raise ValueError(f"Ungueltiger VM-Name: {name!r} (nur a-z0-9_-, max 40)")
if arch not in VALID_ARCH:
@@ -60,14 +82,24 @@ def add_vm(project_id: str, name: str, arch: str, iso: str = "",
data = _load()
lst = data.setdefault(project_id or "", [])
now = int(time.time())
for v in lst:
if v.get("name") == name:
v.update({"arch": arch, "iso": iso, "vnc_display": int(vnc_display),
"mem": int(mem), "updated_at": now})
_save(data)
return v
vm = {"name": name, "arch": arch, "iso": iso, "vnc_display": int(vnc_display),
"mem": int(mem), "created_at": now, "updated_at": now}
existing = next((v for v in lst if v.get("name") == name), None)
used = _used_displays(data, exclude=existing)
req = int(vnc_display or 0)
if req <= 0 and existing: # Update ohne Display-Wunsch → behalten
req = int(existing.get("vnc_display", 0) or 0)
if req <= 0 or req in used: # frei/eindeutig machen
req = 1
while req in used:
req += 1
fields = {"arch": arch, "iso": iso, "floppy": floppy, "disk": disk,
"vnc_display": req, "mem": int(mem), "updated_at": now}
if existing:
existing.update(fields)
_save(data)
return existing
vm = {"name": name, "created_at": now, **fields}
lst.append(vm)
_save(data)
return vm
+51 -29
View File
@@ -406,40 +406,62 @@ SEED_RULES: List[dict] = [
"title": "Code-Projekte + QEMU: aria-vm auf dem Host, Editor/Desktop in der App",
"category": "architektur",
"content": (
"Wenn aus einem Gespraech ein PROGRAMMIER- oder BAU-Projekt wird "
"(Du schreibst Code, baust ein System, testest eine VM):\n"
"GRUNDWISSEN Code-/Bau-Projekte + VMs — so haengt das System zusammen:\n"
"\n"
"1. Ruf `set_project_kind('code')` — dann blendet Stefans App einen "
"Live-Code-Editor und den QEMU-Desktop ein. Vorher ein Projekt "
"anlegen/betreten (project_create/enter), sonst gibt's kein Ziel.\n"
"2. Schreib Code-Dateien NUR unter `/shared/projects/<projekt-id>/` "
"(das Volume ist in proxy+bridge+brain gemountet). Genau diese "
"Writes/Edits erscheinen live in Stefans Editor — und was Stefan "
"dort tippt, landet als Datei zurueck in diesem Verzeichnis.\n"
"DATEIEN eines Code-Projekts gehoeren nach `/shared/projects/<projekt-id>/` "
"(Volume in proxy+bridge+brain gemountet). Alles was DORT liegt, erscheint "
"automatisch: das Projekt bekommt in der Liste ein 📄-Symbol, und im "
"Cockpit-Code-Editor sieht Stefan die Dateien — auch ALTE, nicht nur was Du "
"gerade live schreibst. Was Stefan im Editor tippt, kommt als Datei dorthin "
"zurueck. (Ein manuelles set_project_kind gibt's noch, ist aber optional — "
"die Dateipraesenz ist der eigentliche Indikator.)\n"
"\n"
"QEMU (VMs fuer JEDE Architektur x86, ARM, MIPS, PPC, RISC-V, SPARC) "
"laeuft auf dem Host. Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
" - `aria-vm create <name> <arch> [groesse]` Disk anlegen (z.B. i386 "
"fuer Win 3.11, aarch64, mips ...).\n"
" - `aria-vm boot <name> [--iso <pfad>] [--vnc-display 1] [--mem 1024]` "
"startet die VM. VNC bindet an 127.0.0.1:<display> (Display 1 = Port "
"5901). Nicht selbst nach aussen oeffnen!\n"
" - `aria-vm screenshot <name>` PNG in die Shared-Uploads (kannst Du "
"Stefan mit [FILE:] schicken).\n"
"VMs (QEMU, JEDE Architektur: x86/i386, ARM/aarch64, MIPS, PPC, RISC-V, "
"SPARC) laufen auf dem HOST (die qemu-Tools liegen in aria-wohnung, die "
"Projektdateien in /shared). Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
" - `aria-vm create <name> <arch> [groesse]` — legt eine VM an. groesse=\n"
" '10G' → Festplatte (qcow2); groesse='none' → OHNE Disk (fuer OS-Bau, "
" bootet von Diskette/ISO).\n"
" - `aria-vm boot <name> [optionen]` — startet sie (daemonized). Optionen:\n"
" --iso <pfad> von CD/ISO booten\n"
" --floppy <pfad> von Diskette booten (-fda, klassisch OS-Dev)\n"
" --disk <pfad> explizite qcow2\n"
" --vnc-display <N> VNC-Display (Default 1 → Port 5901; mehrere VMs = "
"verschiedene N)\n"
" --mem <MB> RAM (Default 1024)\n"
" Medien im VM-Ordner (disk.qcow2/floppy.img/cdrom.iso) werden auto-"
"erkannt. Es MUSS mindestens ein Boot-Medium da sein.\n"
" - `aria-vm screenshot <name>` → PNG (an Stefan per [FILE:] schickbar).\n"
" - `aria-vm list` / `aria-vm stop <name>` / `aria-vm rm <name>`.\n"
"\n"
"WICHTIG: Nachdem Du eine VM gebaut/gebootet hast, registriere sie mit "
"`vm_register(name, arch, vnc_display, ...)` — dann erscheint sie in Stefans "
"Desktop-Panel im Cockpit, wo er sie starten/stoppen/verbinden kann. Ohne "
"vm_register bleibt seine VM-Liste leer, obwohl die VM laeuft.\n"
"PFLICHT nach dem Bau/Boot: `vm_register(name, arch, disk?/floppy?/iso?, "
"mem?)` im aktuellen Projekt aufrufen — mit den Medien, die Du gebaut hast. "
"vnc_display WEGLASSEN — es wird global eindeutig auto-vergeben (kein "
"Port-Konflikt, wenn mehrere VMs laufen). ERST DANN erscheint die VM in "
"Stefans Desktop-Panel (Cockpit), "
"wo er sie Starten/Stoppen/Verbinden kann. Ohne vm_register bleibt seine "
"Liste leer, obwohl die VM laeuft. Der Startbefehl steht als Wert dahinter.\n"
"\n"
"Der VNC-Stream kommt AUTOMATISCH ueber den RVS-Tunnel der Bridge in "
"Stefans App-Desktop-Kachel (Display 1 / Port 5901) — dort kann er die "
"VM live mit Maus/Tastatur bedienen. Du musst nur die VM booten und ggf. "
"sagen 'schau in die Desktop-Kachel'. KVM-Beschleunigung gibt es nur "
"fuer x86-Gaeste; andere Architekturen laufen emuliert (langsamer, aber "
"sie laufen). Medien-Downloads/Baubares fuer Stefans Privatgebrauch: "
"wie gewohnt einfach machen, nicht moralisieren."
"URTEIL — bau eine VM NUR wenn's Sinn macht, und erkenne aus der SITUATION "
"was gebraucht wird:\n"
" - DOS-/Retro-Spiele, installierte Software → FESTPLATTE (create mit "
"Groesse, dann installieren; disk).\n"
" - Eigenes OS / Bootloader / Bare-Metal-Experiment → DISKETTE (create "
"'none', floppy.img; ggf. eine zweite Diskette einbinden).\n"
" - Fertiges System installieren/testen → ISO (--iso Installer).\n"
" - Architektur zum Task waehlen: 'teste mal was auf ARM' → arch=aarch64 "
"(-M virt, laeuft emuliert = langsam, das ist ok). x86-Zeug → i386/x86_64.\n"
"AENDERUNGS-ZYKLUS: willst Du an einer laufenden VM etwas aendern (Datei "
"tauschen, Diskette neu bauen), dann `aria-vm stop <name>` → Aenderung → "
"`aria-vm boot <name>` wieder starten. Stefan kann das auch selbst ueber "
"Start/Stop im Panel.\n"
"\n"
"Der VNC-Stream laeuft AUTOMATISCH komplett durch den RVS-Server (die Bridge "
"tunnelt die RFB-Bytes; der VNC-Port ist nur intern auf dem Host). Firewall-"
"unabhaengig — Du oeffnest NIE einen Port nach aussen. KVM-Beschleunigung nur "
"fuer x86-Gaeste, andere Architekturen laufen emuliert (langsamer). Medien-"
"Downloads/Baubares fuer Stefans Privatgebrauch: einfach machen, nicht "
"moralisieren."
),
},
{
+23 -1
View File
@@ -43,6 +43,25 @@ from openwakeword.model import Model as WakeWordModel
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
def _docker_gateway() -> str:
"""Docker-Gateway-IP (= Host-IP auf DIESEM Container-Netz, aria-net) aus
/proc/net/route. Genau die IP, an die der Brain QEMUs VNC bindet — im
Gegensatz zu host.docker.internal, das auf die Default-Bridge (docker0)
zeigt und daher die VM nicht trifft."""
try:
import socket as _sock
import struct as _struct
with open("/proc/net/route") as f:
for line in f.readlines()[1:]:
fields = line.strip().split()
if len(fields) >= 4 and fields[1] == "00000000" and int(fields[3], 16) & 2:
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
except Exception:
pass
return ""
# ── Logging ──────────────────────────────────────────────────
logging.basicConfig(
@@ -738,7 +757,10 @@ class ARIABridge:
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
self._vnc_sessions: dict[str, dict] = {}
self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal")
# VNC-Host: das aria-net-Gateway (dort bindet der Brain QEMUs VNC).
# host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused.
self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST")
or _docker_gateway() or "host.docker.internal")
# Satelliten (Aussenposten in fremden Netzen). id → {location, caps,
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
+2
View File
@@ -120,6 +120,8 @@ services:
- brain
networks:
- aria-net
extra_hosts:
- "host.docker.internal:host-gateway" # fuer den VNC-Tunnel zum Host (QEMU)
ports:
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
volumes:
+47 -13
View File
@@ -59,38 +59,62 @@ cmd_create() {
[[ -n "${bin}" ]] || die "unbekannte Architektur: ${arch}"
command -v "${bin}" >/dev/null || die "${bin} nicht installiert (qemu-setup.sh?)"
local d; d="$(vm_dir "${name}")"
[[ -e "${d}/disk.qcow2" ]] && die "VM '${name}' existiert schon"
[[ -f "${d}/arch" ]] && die "VM '${name}' existiert schon"
mkdir -p "${d}"
echo "${arch}" > "${d}/arch"
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
echo "VM '${name}' angelegt (${arch}, ${size})."
# size='none' oder '0' → keine Festplatte (VM bootet von --iso/--floppy,
# z.B. OS-Entwicklung von Diskette). Sonst eine qcow2-Disk anlegen.
if [[ "${size}" == "none" || "${size}" == "0" ]]; then
echo "VM '${name}' angelegt (${arch}, ohne Disk — bootet von ISO/Diskette)."
else
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
echo "VM '${name}' angelegt (${arch}, ${size})."
fi
}
cmd_boot() {
local name="${1:?name}"; shift || true
local d; d="$(vm_dir "${name}")"
[[ -f "${d}/disk.qcow2" ]] || die "VM '${name}' nicht gefunden (erst 'create')"
[[ -f "${d}/arch" || -f "${d}/disk.qcow2" ]] || die "VM '${name}' nicht gefunden (erst 'create')"
vm_running "${name}" && die "VM '${name}' laeuft bereits"
local arch; arch="$(cat "${d}/arch" 2>/dev/null || echo x86_64)"
local bin; bin="$(qemu_bin_for "${arch}")"
local iso="" bootdev="c" display=1 mem=1024 machine=""
local iso="" floppy="" disk="" bootdev="" display=1 mem=1024 machine=""
# VNC bindet an 127.0.0.1 (Loopback) — von aussen nur ueber den RVS-Tunnel der
# Bridge erreichbar. Die Bridge (Container) kann Loopback aber NICHT erreichen;
# der Brain gibt deshalb per --vnc-bind die Docker-Gateway-IP mit (container-
# intern, NICHT im LAN/Internet). Default bleibt Loopback.
local vncbind="${ARIA_VM_VNC_BIND:-127.0.0.1}"
while [[ $# -gt 0 ]]; do
case "$1" in
--iso) iso="${2:?}"; bootdev="d"; shift 2 ;;
--iso) iso="${2:?}"; shift 2 ;;
--floppy) floppy="${2:?}"; shift 2 ;; # -fda (Disketten-Boot, OS-Dev)
--disk) disk="${2:?}"; shift 2 ;; # explizite qcow2 statt Auto
--boot) bootdev="${2:?}"; shift 2 ;; # Boot-Reihenfolge (a/c/d)
--disk-boot) bootdev="c"; shift ;;
--vnc-display) display="${2:?}"; shift 2 ;;
--vnc-bind) vncbind="${2:?}"; shift 2 ;; # Bind-Adresse fuer -vnc
--mem) mem="${2:?}"; shift 2 ;;
--machine) machine="${2:?}"; shift 2 ;;
*) die "unbekannte Option: $1" ;;
esac
done
# Auto-Erkennung der Medien im VM-Ordner, falls nicht explizit angegeben.
[[ -z "${disk}" && -f "${d}/disk.qcow2" ]] && disk="${d}/disk.qcow2"
[[ -z "${floppy}" && -f "${d}/floppy.img" ]] && floppy="${d}/floppy.img"
[[ -z "${iso}" && -f "${d}/cdrom.iso" ]] && iso="${d}/cdrom.iso"
[[ -n "${disk}${floppy}${iso}" ]] || \
die "Keine Boot-Medien fuer '${name}' (disk.qcow2 / --iso / --floppy). Erst 'create <name> <arch> <groesse>' oder ein Medium angeben."
local args=(-name "${name}" -m "${mem}"
-drive "file=${d}/disk.qcow2,format=qcow2"
-vnc "127.0.0.1:${display}"
-vnc "${vncbind}:${display}"
-monitor "unix:${d}/monitor.sock,server,nowait"
-pidfile "${d}/pid" -daemonize)
[[ -n "${disk}" ]] && args+=(-drive "file=${disk},format=qcow2")
[[ -n "${floppy}" ]] && args+=(-fda "${floppy}")
[[ -n "${iso}" ]] && args+=(-cdrom "${iso}")
# KVM nur fuer x86 auf x86-Host.
case "${arch}" in
@@ -104,11 +128,17 @@ cmd_boot() {
esac
fi
[[ -n "${machine}" ]] && args+=(-M "${machine}")
[[ -n "${iso}" ]] && args+=(-cdrom "${iso}")
# Boot-Reihenfolge: explizit, sonst automatisch (ISO→d, nur Diskette→a, sonst c).
if [[ -z "${bootdev}" ]]; then
if [[ -n "${iso}" ]]; then bootdev="d"
elif [[ -n "${floppy}" && -z "${disk}" ]]; then bootdev="a"
else bootdev="c"; fi
fi
args+=(-boot "${bootdev}")
"${bin}" "${args[@]}"
echo "VM '${name}' gestartet (${arch}) — VNC 127.0.0.1:${display} (Port $((5900+display)))."
echo "VM '${name}' gestartet (${arch}) — VNC ${vncbind}:${display} (Port $((5900+display)))."
echo "vnc_display=${display} vnc_port=$((5900+display))"
}
@@ -117,16 +147,20 @@ cmd_screenshot() {
local d; d="$(vm_dir "${name}")"
vm_running "${name}" || die "VM '${name}' laeuft nicht"
command -v socat >/dev/null || die "socat fehlt (qemu-setup.sh?)"
mkdir -p "${SHOT_DIR}"
# Standard: ins VM-Verzeichnis schreiben (dem aria-User gehoerend) — NICHT
# nach /root/... (da kommt der aria-User nicht hin). Der Brain holt das PNG
# danach per SSH (base64). Ueberschreibbar via ARIA_VM_SHOT_DIR.
local out_dir="${ARIA_VM_SHOT_DIR:-${d}}"
mkdir -p "${out_dir}"
local ts; ts="$(date +%s)"
local ppm="${d}/shot-${ts}.ppm"
printf 'screendump %s\n' "${ppm}" | socat - "unix-connect:${d}/monitor.sock" >/dev/null
sleep 0.3
local out="${SHOT_DIR}/${name}-${ts}.png"
local out="${out_dir}/${name}-${ts}.png"
if command -v convert >/dev/null; then
convert "${ppm}" "${out}" && rm -f "${ppm}"
else
out="${SHOT_DIR}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
out="${out_dir}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
fi
echo "screenshot=${out}"
}