Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aef0347ae | ||
|
|
a49c022308 | ||
|
|
2d3ba024a4 | ||
|
|
1c7157327c | ||
|
|
6c27097c96 | ||
|
|
a91325a04f | ||
|
|
ef826d1ed1 | ||
|
|
933836f0a6 | ||
|
|
e04d8f360b | ||
|
|
4685632294 | ||
|
|
769025c41b | ||
|
|
2005e9b85e | ||
|
|
25abd220ad | ||
|
|
3c6bf0783e | ||
|
|
2832ab3dc9 | ||
|
|
d38d62ba21 | ||
|
|
5890c17ec0 | ||
|
|
239f1094f9 | ||
|
|
055db7c059 | ||
|
|
2a8cbc6c15 |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20203
|
versionCode 20209
|
||||||
versionName "0.2.2.3"
|
versionName "0.2.2.9"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.2.3",
|
"version": "0.2.2.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -637,11 +637,16 @@ export const brainApi = {
|
|||||||
return _send(`/projects/${encodeURIComponent(projectId)}/files`);
|
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 }> {
|
readProjectFile(projectId: string, path: string): Promise<{ projectId: string; path: string; content: string }> {
|
||||||
return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`);
|
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 ─────────────────────────────────────────
|
// ── QEMU-VMs pro Projekt ─────────────────────────────────────────
|
||||||
listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> {
|
listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> {
|
||||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { timeoutMs: 20000 });
|
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 }> {
|
stopProjectVm(projectId: string, name: string): Promise<{ ok: boolean; output: string }> {
|
||||||
return _send(`/projects/${encodeURIComponent(projectId)}/vms/${encodeURIComponent(name)}/stop`, { method: 'POST', timeoutMs: 30000 });
|
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). */
|
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
|
||||||
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
|
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { TileId } from './layout';
|
|||||||
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
||||||
import WorkspaceDock from './WorkspaceDock';
|
import WorkspaceDock from './WorkspaceDock';
|
||||||
import ChatTile from './tiles/ChatTile';
|
import ChatTile from './tiles/ChatTile';
|
||||||
|
import FilesTile from './tiles/FilesTile';
|
||||||
import CodeEditorTile from './tiles/CodeEditorTile';
|
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||||
import DesktopTile from './tiles/DesktopTile';
|
import DesktopTile from './tiles/DesktopTile';
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
|
|||||||
const render = (id: TileId) => {
|
const render = (id: TileId) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'chat': return <ChatTile />;
|
case 'chat': return <ChatTile />;
|
||||||
|
case 'files': return <FilesTile projectId={projectId} focused={active === 'files'} />;
|
||||||
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||||
case 'vnc': return <DesktopTile projectId={projectId} focused={active === 'vnc'} />;
|
case 'vnc': return <DesktopTile projectId={projectId} focused={active === 'vnc'} />;
|
||||||
default: return null;
|
default: return null;
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
|
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
|
||||||
*
|
*
|
||||||
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
|
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
|
||||||
* Desktop gruen, wenn eine VM verbunden ist.
|
* Desktop gruen NUR, wenn im aktiven Projekt eine VM laeuft.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
|
import projectFocus, { FocusSnapshot } from '../services/projectFocus';
|
||||||
import codeFile from '../services/codeFile';
|
import codeFile from '../services/codeFile';
|
||||||
import desktop from '../services/desktop';
|
import brainApi from '../services/brainApi';
|
||||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
import viewMode, { ViewModeValue } from '../services/viewMode';
|
||||||
import ChatScreen from '../screens/ChatScreen';
|
import ChatScreen from '../screens/ChatScreen';
|
||||||
import { TileId } from './layout';
|
import { TileId } from './layout';
|
||||||
import WorkspaceDeck from './WorkspaceDeck';
|
import WorkspaceDeck from './WorkspaceDeck';
|
||||||
|
|
||||||
const COCKPIT_PANELS: TileId[] = ['chat', 'editor', 'vnc'];
|
const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc'];
|
||||||
|
|
||||||
const WorkspaceScreen: React.FC = () => {
|
const WorkspaceScreen: React.FC = () => {
|
||||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
||||||
@@ -29,7 +29,6 @@ const WorkspaceScreen: React.FC = () => {
|
|||||||
useEffect(() => projectFocus.subscribe(setFocus), []);
|
useEffect(() => projectFocus.subscribe(setFocus), []);
|
||||||
|
|
||||||
const pid = focus.focusedProjectId;
|
const pid = focus.focusedProjectId;
|
||||||
const kind = projectFocus.getProjectKind(pid);
|
|
||||||
|
|
||||||
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
|
// Code-Signal: hat der Spiegel schon Dateien fuer dieses Projekt?
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,13 +38,21 @@ const WorkspaceScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [pid]);
|
}, [pid]);
|
||||||
|
|
||||||
// Desktop-Signal + einmaliger Check beim Betreten eines Code-Projekts.
|
// Desktop-Signal: gruener Punkt NUR, wenn im AKTIVEN Projekt wirklich eine VM
|
||||||
|
// laeuft (nicht generell irgendwo). Quelle ist die projektbezogene VM-Liste;
|
||||||
|
// leichtes Nachfassen, damit Start/Stop sich zeitnah zeigt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasDesktop(desktop.getStatus().available);
|
if (!pid) { setHasDesktop(false); return; }
|
||||||
const unsub = desktop.subscribeStatus((s) => setHasDesktop(s.available));
|
let alive = true;
|
||||||
if (kind === 'code') desktop.requestCheck(pid);
|
const check = () => {
|
||||||
return unsub;
|
brainApi.listProjectVms(pid)
|
||||||
}, [pid, kind]);
|
.then(r => { if (alive) setHasDesktop((r.vms || []).some(v => v.running)); })
|
||||||
|
.catch(() => { if (alive) setHasDesktop(false); });
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
const t = setInterval(check, 6000);
|
||||||
|
return () => { alive = false; clearInterval(t); };
|
||||||
|
}, [pid]);
|
||||||
|
|
||||||
const badges = useMemo(() => ({
|
const badges = useMemo(() => ({
|
||||||
editor: hasCode ? '#0096FF' : undefined,
|
editor: hasCode ? '#0096FF' : undefined,
|
||||||
|
|||||||
@@ -77,14 +77,13 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
|||||||
|
|
||||||
var msg=document.getElementById('msg');
|
var msg=document.getElementById('msg');
|
||||||
|
|
||||||
// Verstecktes Eingabefeld → Software-Tastatur des Handys tippt in die VM.
|
// Tastatur laeuft NICHT mehr ueber ein verstecktes WebView-Feld (Android
|
||||||
var kbd=document.createElement('input');
|
// oeffnet die Software-Tastatur dafuer unzuverlaessig). Stattdessen haelt die
|
||||||
kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off');
|
// App ein echtes RN-<TextInput> und ruft window.ariaVncKey.* per
|
||||||
kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false;
|
// injectJavaScript auf → wird unten (nach RFB-Init) definiert.
|
||||||
kbd.style.cssText='position:absolute;left:-1000px;top:0;width:1px;height:1px;opacity:0;';
|
// cp<0x100 → Keysym == Codepoint (Latin-1)
|
||||||
document.body.appendChild(kbd);
|
// sonst → X11-Unicode-Keysym 0x01000000+cp
|
||||||
var SPECIAL={Enter:0xff0d,Backspace:0xff08,Tab:0xff09,Escape:0xff1b,Delete:0xffff,
|
function cpToKeysym(cp){ return cp < 0x100 ? cp : 0x01000000 + cp; }
|
||||||
ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54,Home:0xff50,End:0xff57};
|
|
||||||
|
|
||||||
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
|
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
|
||||||
var RFB = mod.default;
|
var RFB = mod.default;
|
||||||
@@ -99,19 +98,29 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
|||||||
});
|
});
|
||||||
window.__rfb = rfb;
|
window.__rfb = rfb;
|
||||||
|
|
||||||
// Tasten aus dem versteckten Feld an die VM schicken.
|
// Down+Up einer Taste an die VM schicken.
|
||||||
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
||||||
kbd.addEventListener('keydown', function(e){
|
|
||||||
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
|
// Empfaenger-API: die App (RN-<TextInput> + Sondertasten-Leiste) ruft das
|
||||||
});
|
// per injectJavaScript.
|
||||||
kbd.addEventListener('input', function(){
|
// char(cp) druckbares Zeichen (Codepoint)
|
||||||
var v=kbd.value; for(var i=0;i<v.length;i++){ tap(v.charCodeAt(i)); } kbd.value='';
|
// keysym(ks) Sondertaste als fertiges X11-Keysym (Enter/Esc/F1/…)
|
||||||
});
|
// combo(mods,ks) Modifier(-Keysyms) halten → Taste → wieder loslassen
|
||||||
|
// (Strg+C, Strg+Alt+Entf, …). mods = Array von Keysyms.
|
||||||
|
window.ariaVncKey = {
|
||||||
|
char: function(cp){ tap(cpToKeysym(cp)); },
|
||||||
|
keysym: function(ks){ tap(ks); },
|
||||||
|
combo: function(mods, ks){
|
||||||
|
try{
|
||||||
|
for(var i=0;i<mods.length;i++) rfb.sendKey(mods[i], null, true);
|
||||||
|
rfb.sendKey(ks, null, true); rfb.sendKey(ks, null, false);
|
||||||
|
for(var j=mods.length-1;j>=0;j--) rfb.sendKey(mods[j], null, false);
|
||||||
|
}catch(_){}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Steuerungs-API fuer die App (per injectJavaScript).
|
// Steuerungs-API fuer die App (per injectJavaScript).
|
||||||
window.ariaVncCtl = {
|
window.ariaVncCtl = {
|
||||||
focusKeyboard: function(){ try{ kbd.focus(); }catch(_){} },
|
|
||||||
blurKeyboard: function(){ try{ kbd.blur(); }catch(_){} },
|
|
||||||
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
||||||
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
|
* 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 interface TileDef { id: TileId; title: string; icon: string }
|
||||||
|
|
||||||
export const TILE_META: Record<TileId, TileDef> = {
|
export const TILE_META: Record<TileId, TileDef> = {
|
||||||
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
||||||
|
files: { id: 'files', title: 'Dateien', icon: '📁' },
|
||||||
editor: { id: 'editor', title: 'Code', icon: '📝' },
|
editor: { id: 'editor', title: 'Code', icon: '📝' },
|
||||||
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
||||||
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
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 brainApi, { ProjectVm } from '../../services/brainApi';
|
||||||
import VncTile from './VncTile';
|
import VncTile from './VncTile';
|
||||||
|
|
||||||
@@ -22,8 +22,11 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt
|
const [busy, setBusy] = useState(''); // VM-Name, der gerade bootet/stoppt
|
||||||
const [connected, setConnected] = useState<ProjectVm | null>(null);
|
const [connected, setConnected] = useState<ProjectVm | null>(null);
|
||||||
|
const [shotBusy, setShotBusy] = useState('');
|
||||||
|
const [shot, setShot] = useState<{ name: string; b64: string } | null>(null);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
|
if (!projectId) { setVms([]); setErr(''); setLoading(false); return; }
|
||||||
setLoading(true); setErr('');
|
setLoading(true); setErr('');
|
||||||
brainApi.listProjectVms(projectId)
|
brainApi.listProjectVms(projectId)
|
||||||
.then(r => setVms(r.vms || []))
|
.then(r => setVms(r.vms || []))
|
||||||
@@ -51,6 +54,14 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
.finally(() => setBusy(''));
|
.finally(() => setBusy(''));
|
||||||
}, [projectId, load]);
|
}, [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) {
|
if (!focused) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.placeholder}>
|
<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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.bar}>
|
<View style={styles.bar}>
|
||||||
@@ -87,6 +83,8 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
||||||
) : err ? (
|
) : err ? (
|
||||||
<Text style={styles.err}>{err}</Text>
|
<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 ? (
|
) : vms.length === 0 ? (
|
||||||
<Text style={styles.empty}>
|
<Text style={styles.empty}>
|
||||||
Noch keine VM in diesem Projekt.{'\n'}
|
Noch keine VM in diesem Projekt.{'\n'}
|
||||||
@@ -110,6 +108,11 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
<ActivityIndicator color="#0096FF" />
|
<ActivityIndicator color="#0096FF" />
|
||||||
) : vm.running ? (
|
) : 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' }]}>
|
<TouchableOpacity onPress={() => setConnected(vm)} style={[styles.vmBtn, { borderColor: '#0096FF' }]}>
|
||||||
<Text style={[styles.vmBtnText, { color: '#0096FF' }]}>Verbinden</Text>
|
<Text style={[styles.vmBtnText, { color: '#0096FF' }]}>Verbinden</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -128,6 +131,32 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -149,8 +178,15 @@ const styles = StyleSheet.create({
|
|||||||
vmMeta: { color: '#8888AA', fontSize: 12, fontWeight: '400' },
|
vmMeta: { color: '#8888AA', fontSize: 12, fontWeight: '400' },
|
||||||
vmCmd: { color: '#6A9BD0', fontSize: 11, fontFamily: 'monospace', marginTop: 4 },
|
vmCmd: { color: '#6A9BD0', fontSize: 11, fontFamily: 'monospace', marginTop: 4 },
|
||||||
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
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' },
|
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;
|
export default DesktopTile;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2,13 +2,16 @@
|
|||||||
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
||||||
*
|
*
|
||||||
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
|
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
|
||||||
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Eine kleine Steuerungs-Leiste macht
|
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Zwei Bedien-Leisten machen die VM auf
|
||||||
* die VM auf dem Handy bedienbar: Tastatur einblenden (tippt in die VM),
|
* dem Handy voll bedienbar:
|
||||||
* Strg-Alt-Entf, und Fit ↔ 1:1 umschalten.
|
* - ctlBar (oben rechts): Fn-Leiste ein/aus, Software-Tastatur, Fit ↔ 1:1.
|
||||||
|
* - keyBar (oben, Fn): echte Steuertasten, die keine Software-Tastatur
|
||||||
|
* liefert — Esc, Tab, Pfeile, Pos1/Ende/Bild, Einfg/Entf, Enter, F1–F12 und
|
||||||
|
* Sticky-Modifier Strg/Alt/Shift (fuer Strg+C, Strg+Alt+Entf, …).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Keyboard, NativeSyntheticEvent, ScrollView, StyleSheet, Text, TextInput, TextInputChangeEventData, TextInputKeyPressEventData, TouchableOpacity, View } from 'react-native';
|
||||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||||
import desktop from '../../services/desktop';
|
import desktop from '../../services/desktop';
|
||||||
import { NOVNC_HTML } from '../assets/novncHtml';
|
import { NOVNC_HTML } from '../assets/novncHtml';
|
||||||
@@ -19,9 +22,30 @@ interface Props {
|
|||||||
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
|
port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X11-Keysyms fuer Sondertasten, die kein druckbares Zeichen liefern.
|
||||||
|
const KEYSYM = { Backspace: 0xff08, Enter: 0xff0d, Tab: 0xff09 };
|
||||||
|
const MOD = { ctrl: 0xffe3, alt: 0xffe9, shift: 0xffe1 };
|
||||||
|
const cpToKeysym = (cp: number) => (cp < 0x100 ? cp : 0x01000000 + cp);
|
||||||
|
|
||||||
|
// Sondertasten fuer die Fn-Leiste (Label → Keysym).
|
||||||
|
const NAV_KEYS: { label: string; ks: number }[] = [
|
||||||
|
{ label: 'Esc', ks: 0xff1b }, { label: 'Tab', ks: 0xff09 },
|
||||||
|
{ label: '←', ks: 0xff51 }, { label: '↑', ks: 0xff52 }, { label: '↓', ks: 0xff54 }, { label: '→', ks: 0xff53 },
|
||||||
|
{ label: 'Pos1', ks: 0xff50 }, { label: 'Ende', ks: 0xff57 },
|
||||||
|
{ label: 'Bild↑', ks: 0xff55 }, { label: 'Bild↓', ks: 0xff56 },
|
||||||
|
{ label: 'Einfg', ks: 0xff63 }, { label: 'Entf', ks: 0xffff }, { label: '⏎', ks: 0xff0d },
|
||||||
|
];
|
||||||
|
const F_KEYS: { label: string; ks: number }[] = Array.from({ length: 12 }, (_, i) => ({ label: 'F' + (i + 1), ks: 0xffbe + i }));
|
||||||
|
|
||||||
const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
||||||
const webRef = useRef<WebView>(null);
|
const webRef = useRef<WebView>(null);
|
||||||
|
const kbdRef = useRef<TextInput>(null);
|
||||||
|
const bufRef = useRef(''); // Spiegel des TextInput-Textes
|
||||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
||||||
|
const [kbdOn, setKbdOn] = useState(false);
|
||||||
|
const [keyBar, setKeyBar] = useState(false); // Fn-Leiste sichtbar?
|
||||||
|
const [mods, setMods] = useState({ ctrl: false, alt: false, shift: false });
|
||||||
|
const modRef = useRef({ ctrl: false, alt: false, shift: false }); // Spiegel fuer Closures
|
||||||
const unsubDataRef = useRef<null | (() => void)>(null);
|
const unsubDataRef = useRef<null | (() => void)>(null);
|
||||||
|
|
||||||
const teardown = useCallback(() => {
|
const teardown = useCallback(() => {
|
||||||
@@ -34,10 +58,87 @@ const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
|||||||
return () => teardown();
|
return () => teardown();
|
||||||
}, [focused, teardown]);
|
}, [focused, teardown]);
|
||||||
|
|
||||||
|
// Button-Zustand an die ECHTE Tastatur-Sichtbarkeit koppeln: Androids
|
||||||
|
// Zurueck-Taste blendet die Tastatur aus, ohne den TextInput zu blurren —
|
||||||
|
// ueber keyboardDidHide setzen wir das ⌨-Symbol trotzdem zurueck.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focused) return;
|
||||||
|
const show = Keyboard.addListener('keyboardDidShow', () => setKbdOn(true));
|
||||||
|
const hide = Keyboard.addListener('keyboardDidHide', () => setKbdOn(false));
|
||||||
|
return () => { show.remove(); hide.remove(); };
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
const ctl = useCallback((fn: string) => {
|
const ctl = useCallback((fn: string) => {
|
||||||
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
|
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const sendKeysym = useCallback((ks: number) => {
|
||||||
|
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.keysym(${ks}); true;`);
|
||||||
|
}, []);
|
||||||
|
const sendCombo = useCallback((modKeysyms: number[], ks: number) => {
|
||||||
|
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.combo(${JSON.stringify(modKeysyms)}, ${ks}); true;`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Aktive Sticky-Modifier als Keysym-Liste; nach dem Anwenden one-shot zuruecksetzen.
|
||||||
|
const activeMods = useCallback(() => {
|
||||||
|
const m = modRef.current; const a: number[] = [];
|
||||||
|
if (m.ctrl) a.push(MOD.ctrl); if (m.alt) a.push(MOD.alt); if (m.shift) a.push(MOD.shift);
|
||||||
|
return a;
|
||||||
|
}, []);
|
||||||
|
const clearMods = useCallback(() => {
|
||||||
|
if (modRef.current.ctrl || modRef.current.alt || modRef.current.shift) {
|
||||||
|
modRef.current = { ctrl: false, alt: false, shift: false };
|
||||||
|
setMods(modRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const toggleMod = useCallback((k: 'ctrl' | 'alt' | 'shift') => {
|
||||||
|
modRef.current = { ...modRef.current, [k]: !modRef.current[k] };
|
||||||
|
setMods(modRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Eine Taste (fertiges Keysym) senden — mit ggf. aktiven Modifiern.
|
||||||
|
const pressKey = useCallback((ks: number) => {
|
||||||
|
const m = activeMods();
|
||||||
|
if (m.length) { sendCombo(m, ks); clearMods(); } else sendKeysym(ks);
|
||||||
|
}, [activeMods, sendCombo, clearMods, sendKeysym]);
|
||||||
|
|
||||||
|
// Ein druckbares Zeichen senden — mit ggf. aktiven Modifiern (Strg+C etc.).
|
||||||
|
const pressChar = useCallback((cp: number) => {
|
||||||
|
const m = activeMods();
|
||||||
|
if (m.length) { sendCombo(m, cpToKeysym(cp)); clearMods(); }
|
||||||
|
else webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.char(${cp}); true;`);
|
||||||
|
}, [activeMods, sendCombo, clearMods]);
|
||||||
|
|
||||||
|
// Tastatur ein-/ausblenden. Oeffnen: blur→focus erzwingt das Aufklappen auch
|
||||||
|
// dann, wenn der TextInput noch fokussiert ist (Tastatur per Zurueck-Taste
|
||||||
|
// versteckt). Schliessen: Keyboard.dismiss(); den Button-Zustand setzt der
|
||||||
|
// keyboardDidShow/Hide-Listener — nicht hier —, damit er nie „haengen" bleibt.
|
||||||
|
const toggleKbd = useCallback(() => {
|
||||||
|
if (kbdOn) { Keyboard.dismiss(); }
|
||||||
|
else { kbdRef.current?.blur(); setTimeout(() => kbdRef.current?.focus(), 30); }
|
||||||
|
}, [kbdOn]);
|
||||||
|
|
||||||
|
// Druckbare Zeichen: Prefix-Diff des (wachsenden) Feldes → nur neu Getipptes an
|
||||||
|
// die VM. Loeschungen kommen ueber onKeyPress(Backspace), daher hier nur Inserts.
|
||||||
|
const onKbdChange = useCallback((e: NativeSyntheticEvent<TextInputChangeEventData>) => {
|
||||||
|
const text = e.nativeEvent.text || '';
|
||||||
|
const prev = bufRef.current;
|
||||||
|
let i = 0;
|
||||||
|
const min = Math.min(prev.length, text.length);
|
||||||
|
while (i < min && prev.charCodeAt(i) === text.charCodeAt(i)) i++;
|
||||||
|
for (const ch of text.slice(i)) { const cp = ch.codePointAt(0); if (cp) pressChar(cp); }
|
||||||
|
bufRef.current = text;
|
||||||
|
if (text.length > 200) { bufRef.current = ''; kbdRef.current?.setNativeProps({ text: '' }); }
|
||||||
|
}, [pressChar]);
|
||||||
|
|
||||||
|
// Sondertasten der Software-Tastatur: Backspace feuert auf Android zuverlaessig
|
||||||
|
// als keyPress; die Return-Taste (Haken) kommt als onSubmitEditing (s.u.).
|
||||||
|
const onKbdKeyPress = useCallback((e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||||
|
const k = e.nativeEvent.key;
|
||||||
|
if (k === 'Backspace') pressKey(KEYSYM.Backspace);
|
||||||
|
else if (k === 'Enter') pressKey(KEYSYM.Enter);
|
||||||
|
}, [pressKey]);
|
||||||
|
|
||||||
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
||||||
let m: any;
|
let m: any;
|
||||||
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||||
@@ -84,14 +185,34 @@ const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
|||||||
keyboardDisplayRequiresUserAction={false}
|
keyboardDisplayRequiresUserAction={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Verstecktes Eingabefeld: fokussiert → Android-Tastatur tippt in die VM.
|
||||||
|
keyboardType=visible-password schaltet Autokorrektur/Vorschlaege ab und
|
||||||
|
liefert saubere Einzelzeichen. Offscreen, aber fokussierbar. */}
|
||||||
|
<TextInput
|
||||||
|
ref={kbdRef}
|
||||||
|
style={styles.hiddenInput}
|
||||||
|
onChange={onKbdChange}
|
||||||
|
onKeyPress={onKbdKeyPress}
|
||||||
|
onSubmitEditing={() => pressKey(KEYSYM.Enter)}
|
||||||
|
keyboardType="visible-password"
|
||||||
|
returnKeyType="send"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
spellCheck={false}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
caretHidden
|
||||||
|
contextMenuHidden
|
||||||
|
multiline={false}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Steuerungs-Leiste — nur wenn verbunden */}
|
{/* Steuerungs-Leiste — nur wenn verbunden */}
|
||||||
{connected && (
|
{connected && (
|
||||||
<View style={styles.ctlBar}>
|
<View style={styles.ctlBar}>
|
||||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('focusKeyboard')} activeOpacity={0.7}>
|
<TouchableOpacity style={[styles.ctlBtn, keyBar && styles.ctlBtnOn]} onPress={() => setKeyBar(v => !v)} activeOpacity={0.7}>
|
||||||
<Text style={styles.ctlText}>⌨</Text>
|
<Text style={styles.ctlText}>Fn</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('cad')} activeOpacity={0.7}>
|
<TouchableOpacity style={[styles.ctlBtn, kbdOn && styles.ctlBtnOn]} onPress={toggleKbd} activeOpacity={0.7}>
|
||||||
<Text style={styles.ctlTextSmall}>Strg+Alt+Entf</Text>
|
<Text style={styles.ctlText}>⌨</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
|
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
|
||||||
<Text style={styles.ctlText}>⤢</Text>
|
<Text style={styles.ctlText}>⤢</Text>
|
||||||
@@ -99,6 +220,26 @@ const VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fn-Leiste — echte Steuertasten (oben, ueber der Software-Tastatur). */}
|
||||||
|
{connected && keyBar && (
|
||||||
|
<View style={styles.keyBar} pointerEvents="box-none">
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
|
||||||
|
<TouchableOpacity style={[styles.key, mods.ctrl && styles.keyOn]} onPress={() => toggleMod('ctrl')} activeOpacity={0.7}><Text style={styles.keyText}>Strg</Text></TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.key, mods.alt && styles.keyOn]} onPress={() => toggleMod('alt')} activeOpacity={0.7}><Text style={styles.keyText}>Alt</Text></TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.key, mods.shift && styles.keyOn]} onPress={() => toggleMod('shift')} activeOpacity={0.7}><Text style={styles.keyText}>Shift</Text></TouchableOpacity>
|
||||||
|
{NAV_KEYS.map(k => (
|
||||||
|
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" contentContainerStyle={styles.keyRow}>
|
||||||
|
{F_KEYS.map(k => (
|
||||||
|
<TouchableOpacity key={k.label} style={styles.key} onPress={() => pressKey(k.ks)} activeOpacity={0.7}><Text style={styles.keyText}>{k.label}</Text></TouchableOpacity>
|
||||||
|
))}
|
||||||
|
<TouchableOpacity style={styles.key} onPress={() => ctl('cad')} activeOpacity={0.7}><Text style={styles.keyTextSm}>Strg+Alt+Entf</Text></TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{!connected && (
|
{!connected && (
|
||||||
<View style={styles.overlay} pointerEvents="none">
|
<View style={styles.overlay} pointerEvents="none">
|
||||||
<Text style={styles.overlayText}>
|
<Text style={styles.overlayText}>
|
||||||
@@ -119,7 +260,7 @@ const styles = StyleSheet.create({
|
|||||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||||
ctlBar: {
|
ctlBar: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 8,
|
top: 34,
|
||||||
right: 8,
|
right: 8,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
@@ -135,8 +276,21 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
ctlBtnOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
|
||||||
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
|
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
|
||||||
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
|
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
|
||||||
|
// Fokussierbar (nicht display:none), aber aus dem Sichtfeld geschoben.
|
||||||
|
hiddenInput: { position: 'absolute', width: 1, height: 1, top: -100, left: -100, opacity: 0, padding: 0 },
|
||||||
|
keyBar: { position: 'absolute', top: 74, left: 0, right: 0, gap: 5 },
|
||||||
|
keyRow: { paddingHorizontal: 6, gap: 5, alignItems: 'center' },
|
||||||
|
key: {
|
||||||
|
backgroundColor: 'rgba(18,18,42,0.92)', borderColor: '#2A2A3E', borderWidth: 1,
|
||||||
|
borderRadius: 8, paddingHorizontal: 9, paddingVertical: 7, minWidth: 34,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
},
|
||||||
|
keyOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' },
|
||||||
|
keyText: { color: '#E0E0F0', fontSize: 13, fontWeight: '700' },
|
||||||
|
keyTextSm: { color: '#E0E0F0', fontSize: 10, fontWeight: '700' },
|
||||||
overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' },
|
overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' },
|
||||||
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
|
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-6
@@ -1170,16 +1170,21 @@ META_TOOLS = [
|
|||||||
"Traegt eine QEMU-VM in die VM-Liste des AKTUELLEN Projekts ein, "
|
"Traegt eine QEMU-VM in die VM-Liste des AKTUELLEN Projekts ein, "
|
||||||
"damit sie in Stefans Desktop-Panel (Cockpit) erscheint und er sie "
|
"damit sie in Stefans Desktop-Panel (Cockpit) erscheint und er sie "
|
||||||
"starten/stoppen/verbinden kann. Rufe das auf, NACHDEM Du mit aria-vm "
|
"starten/stoppen/verbinden kann. Rufe das auf, NACHDEM Du mit aria-vm "
|
||||||
"eine VM gebaut/gebootet hast (z.B. bei einem OS-Bau-Projekt). "
|
"eine VM gebaut/gebootet hast — mit den Medien, die zum Task passen: "
|
||||||
"vnc_display bestimmt den VNC-Port (Port = 5900+display)."
|
"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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {"type": "string", "description": "VM-Name wie bei aria-vm (a-z0-9_-)."},
|
"name": {"type": "string", "description": "VM-Name wie bei aria-vm (a-z0-9_-)."},
|
||||||
"arch": {"type": "string", "description": "z.B. i386, x86_64, aarch64, mips."},
|
"arch": {"type": "string", "description": "z.B. i386, x86_64, aarch64, mips (Architektur zum Task waehlen)."},
|
||||||
"iso": {"type": "string", "description": "optional: Boot-ISO-Pfad auf dem Host."},
|
"disk": {"type": "string", "description": "optional: qcow2-Pfad (Festplatte)."},
|
||||||
"vnc_display": {"type": "integer", "description": "VNC-Display (Default 1 → Port 5901)."},
|
"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)."},
|
"mem": {"type": "integer", "description": "RAM in MB (Default 1024)."},
|
||||||
},
|
},
|
||||||
"required": ["name", "arch"],
|
"required": ["name", "arch"],
|
||||||
@@ -2839,7 +2844,9 @@ class Agent:
|
|||||||
pid, (arguments.get("name") or "").strip(),
|
pid, (arguments.get("name") or "").strip(),
|
||||||
(arguments.get("arch") or "i386").strip(),
|
(arguments.get("arch") or "i386").strip(),
|
||||||
(arguments.get("iso") or "").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),
|
int(arguments.get("mem") or 1024),
|
||||||
)
|
)
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
|
|||||||
+105
-19
@@ -912,13 +912,24 @@ def project_files(project_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}/file")
|
@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)
|
base = _project_dir(project_id)
|
||||||
target = os.path.realpath(os.path.join(base, path))
|
target = os.path.realpath(os.path.join(base, path))
|
||||||
if target != base and not target.startswith(base + os.sep):
|
if target != base and not target.startswith(base + os.sep):
|
||||||
raise HTTPException(status_code=400, detail="Pfad ausserhalb des Projekts")
|
raise HTTPException(status_code=400, detail="Pfad ausserhalb des Projekts")
|
||||||
if not os.path.isfile(target):
|
if not os.path.isfile(target):
|
||||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
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:
|
if os.path.getsize(target) > _PROJECT_FILE_MAX:
|
||||||
raise HTTPException(status_code=413, detail="Datei zu gross fuer den Editor")
|
raise HTTPException(status_code=413, detail="Datei zu gross fuer den Editor")
|
||||||
try:
|
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")
|
_ARIA_VM_HOST = os.environ.get("ARIA_VM_SSH_HOST", "aria-wohnung")
|
||||||
|
|
||||||
|
|
||||||
def _ssh_aria_vm(*args: str, timeout: int = 25):
|
def _docker_gateway() -> str:
|
||||||
import subprocess
|
"""Docker-Gateway-IP (= Host-IP auf dem Container-Netz), an die QEMU sein VNC
|
||||||
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
|
binden soll: von der Bridge erreichbar, aber NICHT im LAN/Internet. Aus
|
||||||
_ARIA_VM_HOST, "aria-vm", *[str(a) for a in args]]
|
/proc/net/route (Default-Route), kein `ip`-Tool noetig."""
|
||||||
try:
|
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 ""
|
return r.returncode, r.stdout or "", r.stderr or ""
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return 1, "", str(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:
|
def _vm_running_names() -> set:
|
||||||
rc, out, _err = _ssh_aria_vm("list", timeout=15)
|
rc, out, _err = _ssh_aria_vm("list", timeout=15)
|
||||||
names = set()
|
names = set()
|
||||||
@@ -961,20 +993,30 @@ class VmAddBody(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
arch: str = "i386"
|
arch: str = "i386"
|
||||||
iso: str = ""
|
iso: str = ""
|
||||||
|
floppy: str = ""
|
||||||
|
disk: str = ""
|
||||||
vnc_display: int = 1
|
vnc_display: int = 1
|
||||||
mem: int = 1024
|
mem: int = 1024
|
||||||
create_disk: bool = False
|
create_disk: bool = False
|
||||||
size: str = "10G"
|
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:
|
def _vm_boot_cmd(v: dict) -> str:
|
||||||
"""Lesbarer Start-Befehl (aria-vm) als 'Wert' hinter dem VM-Eintrag."""
|
"""Lesbarer Start-Befehl (aria-vm) als 'Wert' hinter dem VM-Eintrag."""
|
||||||
parts = ["aria-vm", "boot", v.get("name", "?"),
|
return "aria-vm " + " ".join(_vm_boot_args(v))
|
||||||
"--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)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}/vms")
|
@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):
|
def project_vm_add(project_id: str, body: VmAddBody):
|
||||||
try:
|
try:
|
||||||
vm = project_vms_mod.add_vm(project_id, body.name, body.arch, body.iso,
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
if body.create_disk:
|
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)
|
vm = project_vms_mod.get_vm(project_id, name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht gefunden")
|
raise HTTPException(status_code=404, detail=f"VM '{name}' nicht gefunden")
|
||||||
args = ["boot", name, "--vnc-display", str(vm.get("vnc_display", 1)),
|
# VNC an die Docker-Gateway-IP binden, damit die Bridge den Stream tunneln
|
||||||
"--mem", str(vm.get("mem", 1024))]
|
# kann (Loopback ist von Containern nicht erreichbar). NICHT im LAN sichtbar.
|
||||||
if vm.get("iso"):
|
boot_args = _vm_boot_args(vm)
|
||||||
args += ["--iso", vm["iso"]]
|
gw = _docker_gateway()
|
||||||
rc, out, err = _ssh_aria_vm(*args, timeout=40)
|
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)),
|
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")
|
@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]}
|
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")
|
@app.get("/conversation/stats")
|
||||||
def conversation_stats():
|
def conversation_stats():
|
||||||
return conversation().stats()
|
return conversation().stats()
|
||||||
|
|||||||
@@ -51,8 +51,30 @@ def get_vm(project_id: str, name: str) -> Optional[dict]:
|
|||||||
return None
|
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 = "",
|
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 ""):
|
if not NAME_RE.match(name or ""):
|
||||||
raise ValueError(f"Ungueltiger VM-Name: {name!r} (nur a-z0-9_-, max 40)")
|
raise ValueError(f"Ungueltiger VM-Name: {name!r} (nur a-z0-9_-, max 40)")
|
||||||
if arch not in VALID_ARCH:
|
if arch not in VALID_ARCH:
|
||||||
@@ -60,14 +82,24 @@ def add_vm(project_id: str, name: str, arch: str, iso: str = "",
|
|||||||
data = _load()
|
data = _load()
|
||||||
lst = data.setdefault(project_id or "", [])
|
lst = data.setdefault(project_id or "", [])
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
for v in lst:
|
existing = next((v for v in lst if v.get("name") == name), None)
|
||||||
if v.get("name") == name:
|
|
||||||
v.update({"arch": arch, "iso": iso, "vnc_display": int(vnc_display),
|
used = _used_displays(data, exclude=existing)
|
||||||
"mem": int(mem), "updated_at": now})
|
req = int(vnc_display or 0)
|
||||||
_save(data)
|
if req <= 0 and existing: # Update ohne Display-Wunsch → behalten
|
||||||
return v
|
req = int(existing.get("vnc_display", 0) or 0)
|
||||||
vm = {"name": name, "arch": arch, "iso": iso, "vnc_display": int(vnc_display),
|
if req <= 0 or req in used: # frei/eindeutig machen
|
||||||
"mem": int(mem), "created_at": now, "updated_at": now}
|
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)
|
lst.append(vm)
|
||||||
_save(data)
|
_save(data)
|
||||||
return vm
|
return vm
|
||||||
|
|||||||
+51
-29
@@ -406,40 +406,62 @@ SEED_RULES: List[dict] = [
|
|||||||
"title": "Code-Projekte + QEMU: aria-vm auf dem Host, Editor/Desktop in der App",
|
"title": "Code-Projekte + QEMU: aria-vm auf dem Host, Editor/Desktop in der App",
|
||||||
"category": "architektur",
|
"category": "architektur",
|
||||||
"content": (
|
"content": (
|
||||||
"Wenn aus einem Gespraech ein PROGRAMMIER- oder BAU-Projekt wird "
|
"GRUNDWISSEN Code-/Bau-Projekte + VMs — so haengt das System zusammen:\n"
|
||||||
"(Du schreibst Code, baust ein System, testest eine VM):\n"
|
|
||||||
"\n"
|
"\n"
|
||||||
"1. Ruf `set_project_kind('code')` — dann blendet Stefans App einen "
|
"DATEIEN eines Code-Projekts gehoeren nach `/shared/projects/<projekt-id>/` "
|
||||||
"Live-Code-Editor und den QEMU-Desktop ein. Vorher ein Projekt "
|
"(Volume in proxy+bridge+brain gemountet). Alles was DORT liegt, erscheint "
|
||||||
"anlegen/betreten (project_create/enter), sonst gibt's kein Ziel.\n"
|
"automatisch: das Projekt bekommt in der Liste ein 📄-Symbol, und im "
|
||||||
"2. Schreib Code-Dateien NUR unter `/shared/projects/<projekt-id>/` "
|
"Cockpit-Code-Editor sieht Stefan die Dateien — auch ALTE, nicht nur was Du "
|
||||||
"(das Volume ist in proxy+bridge+brain gemountet). Genau diese "
|
"gerade live schreibst. Was Stefan im Editor tippt, kommt als Datei dorthin "
|
||||||
"Writes/Edits erscheinen live in Stefans Editor — und was Stefan "
|
"zurueck. (Ein manuelles set_project_kind gibt's noch, ist aber optional — "
|
||||||
"dort tippt, landet als Datei zurueck in diesem Verzeichnis.\n"
|
"die Dateipraesenz ist der eigentliche Indikator.)\n"
|
||||||
"\n"
|
"\n"
|
||||||
"QEMU (VMs fuer JEDE Architektur — x86, ARM, MIPS, PPC, RISC-V, SPARC) "
|
"VMs (QEMU, JEDE Architektur: x86/i386, ARM/aarch64, MIPS, PPC, RISC-V, "
|
||||||
"laeuft auf dem Host. Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
|
"SPARC) laufen auf dem HOST (die qemu-Tools liegen in aria-wohnung, die "
|
||||||
" - `aria-vm create <name> <arch> [groesse]` Disk anlegen (z.B. i386 "
|
"Projektdateien in /shared). Du steuerst sie per `ssh aria-wohnung aria-vm ...`:\n"
|
||||||
"fuer Win 3.11, aarch64, mips ...).\n"
|
" - `aria-vm create <name> <arch> [groesse]` — legt eine VM an. groesse=\n"
|
||||||
" - `aria-vm boot <name> [--iso <pfad>] [--vnc-display 1] [--mem 1024]` "
|
" '10G' → Festplatte (qcow2); groesse='none' → OHNE Disk (fuer OS-Bau, "
|
||||||
"startet die VM. VNC bindet an 127.0.0.1:<display> (Display 1 = Port "
|
" bootet von Diskette/ISO).\n"
|
||||||
"5901). Nicht selbst nach aussen oeffnen!\n"
|
" - `aria-vm boot <name> [optionen]` — startet sie (daemonized). Optionen:\n"
|
||||||
" - `aria-vm screenshot <name>` PNG in die Shared-Uploads (kannst Du "
|
" --iso <pfad> von CD/ISO booten\n"
|
||||||
"Stefan mit [FILE:] schicken).\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"
|
" - `aria-vm list` / `aria-vm stop <name>` / `aria-vm rm <name>`.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"WICHTIG: Nachdem Du eine VM gebaut/gebootet hast, registriere sie mit "
|
"PFLICHT nach dem Bau/Boot: `vm_register(name, arch, disk?/floppy?/iso?, "
|
||||||
"`vm_register(name, arch, vnc_display, ...)` — dann erscheint sie in Stefans "
|
"mem?)` im aktuellen Projekt aufrufen — mit den Medien, die Du gebaut hast. "
|
||||||
"Desktop-Panel im Cockpit, wo er sie starten/stoppen/verbinden kann. Ohne "
|
"vnc_display WEGLASSEN — es wird global eindeutig auto-vergeben (kein "
|
||||||
"vm_register bleibt seine VM-Liste leer, obwohl die VM laeuft.\n"
|
"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"
|
"\n"
|
||||||
"Der VNC-Stream kommt AUTOMATISCH ueber den RVS-Tunnel der Bridge in "
|
"URTEIL — bau eine VM NUR wenn's Sinn macht, und erkenne aus der SITUATION "
|
||||||
"Stefans App-Desktop-Kachel (Display 1 / Port 5901) — dort kann er die "
|
"was gebraucht wird:\n"
|
||||||
"VM live mit Maus/Tastatur bedienen. Du musst nur die VM booten und ggf. "
|
" - DOS-/Retro-Spiele, installierte Software → FESTPLATTE (create mit "
|
||||||
"sagen 'schau in die Desktop-Kachel'. KVM-Beschleunigung gibt es nur "
|
"Groesse, dann installieren; disk).\n"
|
||||||
"fuer x86-Gaeste; andere Architekturen laufen emuliert (langsamer, aber "
|
" - Eigenes OS / Bootloader / Bare-Metal-Experiment → DISKETTE (create "
|
||||||
"sie laufen). Medien-Downloads/Baubares fuer Stefans Privatgebrauch: "
|
"'none', floppy.img; ggf. eine zweite Diskette einbinden).\n"
|
||||||
"wie gewohnt einfach machen, nicht moralisieren."
|
" - 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
@@ -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
|
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 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -738,7 +757,10 @@ class ARIABridge:
|
|||||||
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
|
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
|
||||||
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
||||||
self._vnc_sessions: dict[str, dict] = {}
|
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,
|
# Satelliten (Aussenposten in fremden Netzen). id → {location, caps,
|
||||||
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
|
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
|
||||||
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ services:
|
|||||||
- brain
|
- brain
|
||||||
networks:
|
networks:
|
||||||
- aria-net
|
- aria-net
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway" # fuer den VNC-Tunnel zum Host (QEMU)
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
|
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+47
-13
@@ -59,38 +59,62 @@ cmd_create() {
|
|||||||
[[ -n "${bin}" ]] || die "unbekannte Architektur: ${arch}"
|
[[ -n "${bin}" ]] || die "unbekannte Architektur: ${arch}"
|
||||||
command -v "${bin}" >/dev/null || die "${bin} nicht installiert (qemu-setup.sh?)"
|
command -v "${bin}" >/dev/null || die "${bin} nicht installiert (qemu-setup.sh?)"
|
||||||
local d; d="$(vm_dir "${name}")"
|
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}"
|
mkdir -p "${d}"
|
||||||
echo "${arch}" > "${d}/arch"
|
echo "${arch}" > "${d}/arch"
|
||||||
qemu-img create -f qcow2 "${d}/disk.qcow2" "${size}" >/dev/null
|
# size='none' oder '0' → keine Festplatte (VM bootet von --iso/--floppy,
|
||||||
echo "VM '${name}' angelegt (${arch}, ${size})."
|
# 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() {
|
cmd_boot() {
|
||||||
local name="${1:?name}"; shift || true
|
local name="${1:?name}"; shift || true
|
||||||
local d; d="$(vm_dir "${name}")"
|
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"
|
vm_running "${name}" && die "VM '${name}' laeuft bereits"
|
||||||
local arch; arch="$(cat "${d}/arch" 2>/dev/null || echo x86_64)"
|
local arch; arch="$(cat "${d}/arch" 2>/dev/null || echo x86_64)"
|
||||||
local bin; bin="$(qemu_bin_for "${arch}")"
|
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
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
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 ;;
|
--disk-boot) bootdev="c"; shift ;;
|
||||||
--vnc-display) display="${2:?}"; shift 2 ;;
|
--vnc-display) display="${2:?}"; shift 2 ;;
|
||||||
|
--vnc-bind) vncbind="${2:?}"; shift 2 ;; # Bind-Adresse fuer -vnc
|
||||||
--mem) mem="${2:?}"; shift 2 ;;
|
--mem) mem="${2:?}"; shift 2 ;;
|
||||||
--machine) machine="${2:?}"; shift 2 ;;
|
--machine) machine="${2:?}"; shift 2 ;;
|
||||||
*) die "unbekannte Option: $1" ;;
|
*) die "unbekannte Option: $1" ;;
|
||||||
esac
|
esac
|
||||||
done
|
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}"
|
local args=(-name "${name}" -m "${mem}"
|
||||||
-drive "file=${d}/disk.qcow2,format=qcow2"
|
-vnc "${vncbind}:${display}"
|
||||||
-vnc "127.0.0.1:${display}"
|
|
||||||
-monitor "unix:${d}/monitor.sock,server,nowait"
|
-monitor "unix:${d}/monitor.sock,server,nowait"
|
||||||
-pidfile "${d}/pid" -daemonize)
|
-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.
|
# KVM nur fuer x86 auf x86-Host.
|
||||||
case "${arch}" in
|
case "${arch}" in
|
||||||
@@ -104,11 +128,17 @@ cmd_boot() {
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
[[ -n "${machine}" ]] && args+=(-M "${machine}")
|
[[ -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}")
|
args+=(-boot "${bootdev}")
|
||||||
|
|
||||||
"${bin}" "${args[@]}"
|
"${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))"
|
echo "vnc_display=${display} vnc_port=$((5900+display))"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,16 +147,20 @@ cmd_screenshot() {
|
|||||||
local d; d="$(vm_dir "${name}")"
|
local d; d="$(vm_dir "${name}")"
|
||||||
vm_running "${name}" || die "VM '${name}' laeuft nicht"
|
vm_running "${name}" || die "VM '${name}' laeuft nicht"
|
||||||
command -v socat >/dev/null || die "socat fehlt (qemu-setup.sh?)"
|
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 ts; ts="$(date +%s)"
|
||||||
local ppm="${d}/shot-${ts}.ppm"
|
local ppm="${d}/shot-${ts}.ppm"
|
||||||
printf 'screendump %s\n' "${ppm}" | socat - "unix-connect:${d}/monitor.sock" >/dev/null
|
printf 'screendump %s\n' "${ppm}" | socat - "unix-connect:${d}/monitor.sock" >/dev/null
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
local out="${SHOT_DIR}/${name}-${ts}.png"
|
local out="${out_dir}/${name}-${ts}.png"
|
||||||
if command -v convert >/dev/null; then
|
if command -v convert >/dev/null; then
|
||||||
convert "${ppm}" "${out}" && rm -f "${ppm}"
|
convert "${ppm}" "${out}" && rm -f "${ppm}"
|
||||||
else
|
else
|
||||||
out="${SHOT_DIR}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
|
out="${out_dir}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
|
||||||
fi
|
fi
|
||||||
echo "screenshot=${out}"
|
echo "screenshot=${out}"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user