feat(app): Workbench-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole (0.2.2.0)
Cockpit neu gedacht — das Pinch-Zoom-Landkarten-Konzept war auf 5 Zoll fummelig: - WorkspaceDeck + WorkspaceDock: Taskleiste unten (Chat · Code · Desktop), Ein-Tap-Panelwechsel im Daumenbereich, animierter Indikator, Aktivitaets- Badges (Code blau / Desktop gruen), Safe-Area, blendet bei offener Tastatur aus. Panels bildschirmfuellend + immer gemountet (kein Remount). - noVNC bedienbar: novncHtml bekommt Tastatur-Bridge (verstecktes Input-Feld → rfb.sendKey inkl. Enter/Backspace/Pfeile), Strg-Alt-Entf, Fit↔1:1. VncTile zeigt eine Steuerungs-Leiste (⌨ / Strg+Alt+Entf / ⤢). - Entfernt: WorkspaceCanvas, Tile, PreviewTile, CockpitOverviewButton, cockpitNav (Pinch-Canvas + Uebersichts-Button obsolet). layout.ts auf Panel-Metadaten geschlankt. Header nur noch mit Kompakt/Cockpit-Umschalter. tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,25 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2.0] — 2026-07-17 — Workbench: Taskleisten-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole
|
||||
|
||||
### Geändert
|
||||
|
||||
**Cockpit-Navigation neu gedacht — Dock statt Pinch-Canvas**
|
||||
- Die Zoom-Landkarte (mit 2 Fingern rauszoomen, Kachel finden, reintippen) fühlte sich auf 5 Zoll fummelig an. Ersetzt durch eine **Taskleiste unten im Daumenbereich** (Chat · Code · Desktop): **ein Tap wechselt sofort** das Panel, ein animierter Indikator gleitet unter das aktive Icon. Desktop-*Umfang*, Handy-*Ergonomie*.
|
||||
- Jedes Panel ist **bildschirmfüllend** und Handy-optimiert; alle bleiben gemountet (nur das aktive ist sichtbar) → kein Remount, Chat behält RVS/Audio/Queue, WebViews ihren Zustand.
|
||||
- Das Dock **blendet bei offener Tastatur aus** (mehr Platz zum Tippen) und respektiert die Gesten-Navigationsleiste (Safe-Area).
|
||||
- **Aktivitäts-Punkte** am Dock: Code blau, sobald Dateien da sind; Desktop grün, wenn die VM verbunden ist.
|
||||
- Entfällt: Pinch/Pan-Canvas, Übersichts-Button im Header, die „Landkarten"-Thumbnails.
|
||||
|
||||
**noVNC-Konsole endlich bedienbar**
|
||||
- Steuerungs-Leiste im Desktop-Panel: **⌨ Tastatur** (blendet die Handy-Tastatur ein, tippt direkt in die VM — inkl. Enter/Backspace/Pfeiltasten), **Strg+Alt+Entf**, und **⤢ Fit ↔ 1:1**. Tippen/Ziehen steuert weiterhin die Maus.
|
||||
|
||||
### Deploy
|
||||
Nur **App neu bauen** (kein Backend). APK 0.2.2.0.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1.9] — 2026-07-17 — Kompakt ↔ Cockpit: Umschalter für den Kachel-Desktop
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -500,7 +500,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
|
||||
### Features
|
||||
|
||||
- **Desktop-Workspace (zoombarer Canvas)**: Die App ist eine desktop-artige Arbeitsfläche — rausgezoomt eine **Landkarte aus Kacheln** (Chat/Editor/Desktop/Vorschau), die man mit **2 Fingern zoomt und verschiebt**; Tippen auf eine Kachel zoomt sie voll auf und macht sie echt bedienbar („Übersicht + Fokus"). Reiner Chat verhält sich exakt wie bisher (eine Kachel, dauerhaft fokussiert). Zurück per „⤢ Übersicht" oder Hardware-Back. Basiert auf gesture-handler + reanimated; interaktive Inhalte werden nie unter Zoom-Transform gerendert (Keyboard/Touch bleiben korrekt)
|
||||
- **Desktop-Workbench (Kompakt ↔ Cockpit)**: Umschalter oben rechts im Header. **Kompakt** = klassischer Vollbild-Chat (Default). **Cockpit** = Workbench mit **Taskleiste unten** (Chat · Code · Desktop) — ein Daumen-Tap wechselt sofort das Panel, animierter Indikator, Aktivitäts-Punkte (Code blau / Desktop grün). Jedes Panel bildschirmfüllend und Handy-optimiert; Dock blendet bei offener Tastatur aus. Desktop-Umfang, aber auf 5 Zoll bedienbar
|
||||
- **Live-Code-Editor** (Code-Projekte): Wird ein Projekt zum Code-Projekt (`set_project_kind('code')`), zeigt eine Editor-Kachel **live, was ARIA schreibt** (Syntax-Highlighting, offline) und du kannst selbst editieren → zurück an ARIA. ARIAs `Write`/`Edit` unter `/shared/projects/<id>/` werden im Proxy abgefangen und als `code_file` gespiegelt; deine Edits kommen als `code_file_edit` pfad-sicher zurück
|
||||
- **Live-Desktop per VNC (durch RVS getunnelt)**: ARIA baut/testet VMs mit **QEMU für jede Architektur** (x86/ARM/MIPS/PPC/RISC-V/SPARC, Host-Helper `aria-vm`, KVM für x86). Der QEMU-Desktop erscheint **live in der Desktop-Kachel** — noVNC in der WebView, RFB-Bytes werden als Base64 über RVS gebrückt (Bridge ↔ QEMU `127.0.0.1:5901`). Du bedienst die VM **live mit Maus/Tastatur**, NAT-sicher, kein offener Port am Host
|
||||
- Text-Chat mit ARIA
|
||||
@@ -1044,7 +1044,8 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] Background Audio Service: TTS, Wake-Word-Lauschen + Aufnahme laufen auch bei minimierter App weiter (Foreground-Service mit mediaPlayback|microphone, dynamische Notification)
|
||||
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
|
||||
- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
|
||||
- [x] **Desktop-Workspace**: zoom-/verschiebbarer Kachel-Canvas (gesture-handler + reanimated), „Übersicht + Fokus" — Chat/Editor/Desktop/Vorschau als Kacheln; reiner Chat unverändert (eine Kachel)
|
||||
- [x] **Desktop-Workbench**: Taskleisten-Dock (Chat · Code · Desktop), Ein-Tap-Panelwechsel im Daumenbereich, Aktivitäts-Badges, keyboard-aware; Kompakt↔Cockpit-Umschalter. (Ersetzte den anfänglichen Pinch-Zoom-Kachel-Canvas — auf 5 Zoll zu fummelig)
|
||||
- [x] **noVNC-Konsole bedienbar**: Tastatur-Einblendung (tippt in die VM), Strg+Alt+Entf, Fit↔1:1
|
||||
- [x] **Live-Code-Editor** für Code-Projekte (WebView, offline, bidirektional) — ARIAs Write/Edit unter `/shared/projects/<id>/` live gespiegelt (`code_file`), eigene Edits zurück (`code_file_edit`)
|
||||
- [x] **QEMU für alle Architekturen** (Host-Helper `aria-vm`, `qemu-setup.sh`) + `set_project_kind`-Tool + Seed-Regel
|
||||
- [x] **VNC-Live-Desktop durch RVS getunnelt** (Bridge RFB-TCP ↔ RVS, noVNC-WebView mit WebSocket-Shim) — VM live mit Maus/Tastatur bedienbar
|
||||
|
||||
@@ -15,7 +15,6 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import ViewModeToggle from './src/components/ViewModeToggle';
|
||||
import CockpitOverviewButton from './src/components/CockpitOverviewButton';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||
@@ -172,7 +171,6 @@ const App: React.FC = () => {
|
||||
options={{
|
||||
title: 'ARIA Chat',
|
||||
headerTitle: 'ARIA Cockpit',
|
||||
headerLeft: () => <CockpitOverviewButton />,
|
||||
headerRight: () => <ViewModeToggle />,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* CockpitOverviewButton — Header-Button (links) für „⤢ Übersicht".
|
||||
*
|
||||
* Nur sichtbar im Cockpit-Modus, wenn gerade eine Kachel fokussiert ist. Sitzt
|
||||
* im Navigations-Header, kollidiert also mit nichts im Chat (früher hing er über
|
||||
* dem Abbrechen-Button der „ARIA denkt"-Leiste).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
|
||||
import cockpitNav from '../services/cockpitNav';
|
||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
||||
|
||||
const CockpitOverviewButton: React.FC = () => {
|
||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
||||
const [focused, setFocused] = useState<boolean>(cockpitNav.isFocused());
|
||||
|
||||
useEffect(() => viewMode.subscribe(setMode), []);
|
||||
useEffect(() => cockpitNav.subscribe(setFocused), []);
|
||||
|
||||
if (mode !== 'cockpit' || !focused) return null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => cockpitNav.requestOverview()}
|
||||
style={styles.pill}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text style={styles.text}>⤢ Übersicht</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pill: {
|
||||
marginLeft: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#0096FF',
|
||||
backgroundColor: '#0A1F33',
|
||||
},
|
||||
text: { color: '#0096FF', fontSize: 13, fontWeight: '700' },
|
||||
});
|
||||
|
||||
export default CockpitOverviewButton;
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* cockpitNav — kleine Brücke zwischen WorkspaceCanvas (Fokus-Zustand) und dem
|
||||
* Navigations-Header, damit der „⤢ Übersicht"-Button im Header sitzen kann
|
||||
* (kollidiert dort mit nichts im Chat) statt im überfüllten Chat-Inhalt.
|
||||
*
|
||||
* Der Canvas meldet, ob gerade eine Kachel fokussiert ist, und registriert eine
|
||||
* Aktion zum Zurück-zur-Übersicht. Der Header abonniert den Zustand und ruft die
|
||||
* Aktion. Muster wie services/rvs.ts (Singleton).
|
||||
*/
|
||||
|
||||
type Sub = (focused: boolean) => void;
|
||||
|
||||
class CockpitNav {
|
||||
private focused = false;
|
||||
private overviewHandler: (() => void) | null = null;
|
||||
private subs: Sub[] = [];
|
||||
|
||||
isFocused(): boolean { return this.focused; }
|
||||
|
||||
setFocused(f: boolean): void {
|
||||
if (this.focused === f) return;
|
||||
this.focused = f;
|
||||
this.subs.forEach((cb) => cb(f));
|
||||
}
|
||||
|
||||
/** Canvas registriert seine „zur Übersicht"-Aktion (null beim Unmount). */
|
||||
registerOverview(fn: (() => void) | null): void {
|
||||
this.overviewHandler = fn;
|
||||
}
|
||||
|
||||
requestOverview(): void {
|
||||
this.overviewHandler?.();
|
||||
}
|
||||
|
||||
subscribe(cb: Sub): () => void {
|
||||
this.subs.push(cb);
|
||||
cb(this.focused);
|
||||
return () => { this.subs = this.subs.filter((s) => s !== cb); };
|
||||
}
|
||||
}
|
||||
|
||||
const cockpitNav = new CockpitNav();
|
||||
export default cockpitNav;
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Tile — leichte Thumbnail-Darstellung einer Kachel in der gezoomten "Landkarte".
|
||||
*
|
||||
* Zeigt NUR Icon + Titel (+ optionalen Untertitel). Die schweren, interaktiven
|
||||
* Inhalte (ChatScreen, WebViews) liegen NICHT hier, sondern in der separaten
|
||||
* Identity-Content-Ebene des WorkspaceCanvas — Thumbnails werden nie skaliert
|
||||
* interaktiv. Ein Tap fokussiert die Kachel (zoomt sie voll auf).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { runOnJS } from 'react-native-reanimated';
|
||||
import { TileId, TileRect, TILE_META } from './layout';
|
||||
|
||||
interface Props {
|
||||
id: TileId;
|
||||
/** Welt-View-lokales Rect (relativ zur Bounds-Ecke). */
|
||||
rect: TileRect;
|
||||
subtitle?: string;
|
||||
onFocus: (id: TileId) => void;
|
||||
}
|
||||
|
||||
const Tile: React.FC<Props> = ({ id, rect, subtitle, onFocus }) => {
|
||||
const meta = TILE_META[id];
|
||||
const tap = Gesture.Tap()
|
||||
.maxDuration(300)
|
||||
.onEnd((_e, success) => {
|
||||
if (success) runOnJS(onFocus)(id);
|
||||
});
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={tap}>
|
||||
<View style={[styles.tile, { left: rect.x, top: rect.y, width: rect.w, height: rect.h }]}>
|
||||
<Text style={styles.icon}>{meta.icon}</Text>
|
||||
<Text style={styles.title}>{meta.title}</Text>
|
||||
{!!subtitle && <Text style={styles.subtitle} numberOfLines={2}>{subtitle}</Text>}
|
||||
<Text style={styles.hint}>Tippen zum Öffnen</Text>
|
||||
</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tile: {
|
||||
position: 'absolute',
|
||||
backgroundColor: '#12122A',
|
||||
borderRadius: 32,
|
||||
borderWidth: 3,
|
||||
borderColor: '#1E1E2E',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
icon: { fontSize: 220, marginBottom: 24 },
|
||||
title: { color: '#FFFFFF', fontSize: 96, fontWeight: '800' },
|
||||
subtitle: { color: '#9090B0', fontSize: 52, marginTop: 20, textAlign: 'center' },
|
||||
hint: { color: '#555570', fontSize: 46, marginTop: 40 },
|
||||
});
|
||||
|
||||
export default Tile;
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* WorkspaceCanvas — die zoom-/verschiebbare "Landkarte" plus Fokus-Modus.
|
||||
*
|
||||
* Zwei Ebenen uebereinander:
|
||||
* 1. Welt-Ebene (skaliert/verschoben): nur leichte Thumbnail-Kacheln. Hier
|
||||
* wirken 2-Finger-Pinch-Zoom + 2-Finger-Pan (nur in der Uebersicht).
|
||||
* 2. Identity-Content-Ebene (Scale 1, nie transformiert): die schweren,
|
||||
* interaktiven Inhalte (ChatScreen + WebViews). Alle sichtbaren Kacheln
|
||||
* sind hier IMMER gemountet; nur die fokussierte ist per display sichtbar.
|
||||
* Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet
|
||||
* beim Fokuswechsel.
|
||||
*
|
||||
* Tap auf eine Thumbnail-Kachel → Fokus (voll aufgezoomt + interaktiv). Der
|
||||
* "⤢ Übersicht"-Button bzw. der Hardware-Back fuehren zurueck zur Landkarte.
|
||||
* Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert und der
|
||||
* Canvas verhaelt sich exakt wie der bisherige Vollbild-Chat.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { BackHandler, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
|
||||
import {
|
||||
boundsOf, Camera, focusCamera, overviewCamera, TileId, TILE_RECTS, toLocal,
|
||||
} from './layout';
|
||||
import cockpitNav from '../services/cockpitNav';
|
||||
import Tile from './Tile';
|
||||
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
||||
import ChatTile from './tiles/ChatTile';
|
||||
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||
import VncTile from './tiles/VncTile';
|
||||
import PreviewTile from './tiles/PreviewTile';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
visibleTiles: TileId[];
|
||||
subtitles?: Partial<Record<TileId, string>>;
|
||||
}
|
||||
|
||||
const ANIM = { duration: 260 };
|
||||
const MIN_SCALE = 0.15;
|
||||
const MAX_SCALE = 4;
|
||||
|
||||
const WorkspaceCanvas: React.FC<Props> = ({ projectId, visibleTiles, subtitles }) => {
|
||||
const { width: vw, height: vh } = useWindowDimensions();
|
||||
const [focusedTileId, setFocusedTileId] = useState<TileId | null>('chat');
|
||||
|
||||
const visibleKey = visibleTiles.join(',');
|
||||
const bounds = useMemo(() => boundsOf(visibleTiles), [visibleKey]);
|
||||
const worldW = bounds.w;
|
||||
const worldH = bounds.h;
|
||||
const { loaded: layoutLoaded, getFocus, saveFocus } = useWorkspaceLayout(projectId);
|
||||
|
||||
// Kamera (shared values fuer 60fps auf dem UI-Thread).
|
||||
const scale = useSharedValue(1);
|
||||
const tx = useSharedValue(0);
|
||||
const ty = useSharedValue(0);
|
||||
const savedScale = useSharedValue(1);
|
||||
const savedTx = useSharedValue(0);
|
||||
const savedTy = useSharedValue(0);
|
||||
|
||||
// Verschwindet die fokussierte Kachel aus der Sichtbarkeit → auf Chat zurueck.
|
||||
useEffect(() => {
|
||||
if (focusedTileId && !visibleTiles.includes(focusedTileId)) {
|
||||
setFocusedTileId('chat');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visibleKey]);
|
||||
|
||||
// Zuletzt fokussierte Kachel pro Projekt wiederherstellen.
|
||||
useEffect(() => {
|
||||
if (!layoutLoaded) return;
|
||||
const stored = getFocus();
|
||||
if (stored === undefined) return;
|
||||
if (stored === null || visibleTiles.includes(stored)) setFocusedTileId(stored);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId, layoutLoaded, visibleKey]);
|
||||
|
||||
// Fokus-Wahl persistieren.
|
||||
useEffect(() => {
|
||||
if (layoutLoaded) saveFocus(focusedTileId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusedTileId, layoutLoaded]);
|
||||
|
||||
// Kamera auf das Ziel fahren (Fokus-Rect oder Uebersicht).
|
||||
useEffect(() => {
|
||||
const cam: Camera = focusedTileId
|
||||
? focusCamera(toLocal(TILE_RECTS[focusedTileId], bounds), worldW, worldH, vw, vh)
|
||||
: overviewCamera(worldW, worldH, vw, vh);
|
||||
scale.value = withTiming(cam.scale, ANIM);
|
||||
tx.value = withTiming(cam.tx, ANIM);
|
||||
ty.value = withTiming(cam.ty, ANIM);
|
||||
savedScale.value = cam.scale;
|
||||
savedTx.value = cam.tx;
|
||||
savedTy.value = cam.ty;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusedTileId, visibleKey, vw, vh]);
|
||||
|
||||
// Fokus-Zustand + „zur Uebersicht"-Aktion an den Header (cockpitNav) melden,
|
||||
// damit der Uebersicht-Button dort sitzt (kollidiert nicht mit dem Chat).
|
||||
useEffect(() => {
|
||||
cockpitNav.setFocused(focusedTileId != null);
|
||||
cockpitNav.registerOverview(() => setFocusedTileId(null));
|
||||
return () => { cockpitNav.registerOverview(null); cockpitNav.setFocused(false); };
|
||||
}, [focusedTileId]);
|
||||
|
||||
// Hardware-Back: im Fokus → zurueck zur Uebersicht.
|
||||
useEffect(() => {
|
||||
const onBack = () => {
|
||||
if (focusedTileId) {
|
||||
setFocusedTileId(null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', onBack);
|
||||
return () => sub.remove();
|
||||
}, [focusedTileId]);
|
||||
|
||||
// Gesten nur in der Uebersicht (Fokus-Modus: Touches fallen an die Kachel).
|
||||
const gesturesEnabled = !focusedTileId;
|
||||
const canvasGesture = useMemo(() => {
|
||||
const pinch = Gesture.Pinch()
|
||||
.enabled(gesturesEnabled)
|
||||
.onUpdate((e) => {
|
||||
'worklet';
|
||||
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, savedScale.value * e.scale));
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet';
|
||||
savedScale.value = scale.value;
|
||||
});
|
||||
const pan = Gesture.Pan()
|
||||
.enabled(gesturesEnabled)
|
||||
.minPointers(2)
|
||||
.onUpdate((e) => {
|
||||
'worklet';
|
||||
tx.value = savedTx.value + e.translationX;
|
||||
ty.value = savedTy.value + e.translationY;
|
||||
})
|
||||
.onEnd(() => {
|
||||
'worklet';
|
||||
savedTx.value = tx.value;
|
||||
savedTy.value = ty.value;
|
||||
});
|
||||
return Gesture.Simultaneous(pinch, pan);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gesturesEnabled]);
|
||||
|
||||
const worldStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: tx.value },
|
||||
{ translateY: ty.value },
|
||||
{ scale: scale.value },
|
||||
],
|
||||
}));
|
||||
|
||||
const renderContent = (id: TileId) => {
|
||||
switch (id) {
|
||||
case 'chat': return <ChatTile />;
|
||||
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||
case 'vnc': return <VncTile projectId={projectId} focused={focusedTileId === 'vnc'} />;
|
||||
case 'preview': return <PreviewTile />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{/* Ebene 1 — skalierte Landkarte mit Thumbnails */}
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'none' : 'auto'}>
|
||||
<GestureDetector gesture={canvasGesture}>
|
||||
<Animated.View style={[styles.world, { width: worldW, height: worldH }, worldStyle]}>
|
||||
{visibleTiles.map((id) => (
|
||||
<Tile
|
||||
key={id}
|
||||
id={id}
|
||||
rect={toLocal(TILE_RECTS[id], bounds)}
|
||||
subtitle={subtitles?.[id]}
|
||||
onFocus={setFocusedTileId}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
|
||||
{/* Ebene 2 — Identity-Content, immer gemountet, nur fokussierte sichtbar */}
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'box-none' : 'none'}>
|
||||
{visibleTiles.map((id) => (
|
||||
<View
|
||||
key={id}
|
||||
style={[StyleSheet.absoluteFill, { display: focusedTileId === id ? 'flex' : 'none' }]}
|
||||
>
|
||||
{renderContent(id)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Steuerung — der „⤢ Übersicht"-Button sitzt im Header (cockpitNav),
|
||||
nicht hier im Chat-Inhalt. */}
|
||||
{!focusedTileId && (
|
||||
<View style={styles.hintBar} pointerEvents="none">
|
||||
<Text style={styles.hintText}>Kachel antippen zum Öffnen · 2 Finger: zoomen & schieben</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
world: { position: 'absolute', left: 0, top: 0 },
|
||||
hintBar: {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
hintText: {
|
||||
color: '#9090B0',
|
||||
fontSize: 12,
|
||||
backgroundColor: 'rgba(18,18,42,0.85)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export default WorkspaceCanvas;
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* WorkspaceDeck — die Workbench: Vollbild-Panels + Taskleisten-Dock unten.
|
||||
*
|
||||
* Statt einer Zoom-Landkarte: jedes Panel ist bildschirmfuellend und Handy-
|
||||
* optimiert, das Dock wechselt per Daumen-Tap sofort. Alle Panels sind IMMER
|
||||
* gemountet (nur das aktive ist via display sichtbar) → kein Remount, Chat
|
||||
* behaelt RVS/Audio/Queue, WebViews behalten ihren Zustand.
|
||||
*
|
||||
* Bei offener Tastatur blendet das Dock aus (mehr Platz zum Tippen).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Keyboard, StyleSheet, View } from 'react-native';
|
||||
import { TileId } from './layout';
|
||||
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
||||
import WorkspaceDock from './WorkspaceDock';
|
||||
import ChatTile from './tiles/ChatTile';
|
||||
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||
import VncTile from './tiles/VncTile';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
panels: TileId[];
|
||||
badges?: Partial<Record<TileId, string>>;
|
||||
}
|
||||
|
||||
const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
|
||||
const [active, setActive] = useState<TileId>('chat');
|
||||
const [kbVisible, setKbVisible] = useState(false);
|
||||
const { loaded, getFocus, saveFocus } = useWorkspaceLayout(projectId);
|
||||
|
||||
// Aktives Panel pro Projekt wiederherstellen.
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
const stored = getFocus();
|
||||
if (stored && panels.includes(stored)) setActive(stored);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId, loaded]);
|
||||
|
||||
// Falls das aktive Panel wegfaellt → erstes nehmen.
|
||||
useEffect(() => {
|
||||
if (!panels.includes(active)) setActive(panels[0] || 'chat');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [panels.join(',')]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) saveFocus(active);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const s1 = Keyboard.addListener('keyboardDidShow', () => setKbVisible(true));
|
||||
const s2 = Keyboard.addListener('keyboardDidHide', () => setKbVisible(false));
|
||||
return () => { s1.remove(); s2.remove(); };
|
||||
}, []);
|
||||
|
||||
const render = (id: TileId) => {
|
||||
switch (id) {
|
||||
case 'chat': return <ChatTile />;
|
||||
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||
case 'vnc': return <VncTile projectId={projectId} focused={active === 'vnc'} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.stack}>
|
||||
{panels.map((id) => (
|
||||
<View
|
||||
key={id}
|
||||
style={[StyleSheet.absoluteFill, { display: active === id ? 'flex' : 'none' }]}
|
||||
>
|
||||
{render(id)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!kbVisible && (
|
||||
<WorkspaceDock panels={panels} active={active} badges={badges} onSelect={setActive} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||
stack: { flex: 1, position: 'relative' },
|
||||
});
|
||||
|
||||
export default WorkspaceDeck;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* WorkspaceDock — die Taskleiste unten (Daumenzone). Ein Tap wechselt sofort
|
||||
* das Panel; ein animierter Indikator gleitet unter das aktive Icon. Kleine
|
||||
* Aktivitaets-Punkte (Badges) zeigen z.B. „Desktop verbunden" / „Code da".
|
||||
*
|
||||
* Das ist der Kern des Workbench-Gefuehls: Desktop-Umfang, aber Handy-schnell
|
||||
* per Daumen erreichbar — statt einer Zoom-Landkarte.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { LayoutChangeEvent, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { TileId, TILE_META } from './layout';
|
||||
|
||||
interface Props {
|
||||
panels: TileId[];
|
||||
active: TileId;
|
||||
badges?: Partial<Record<TileId, string>>; // TileId → Punkt-Farbe (undefined = kein Punkt)
|
||||
onSelect: (id: TileId) => void;
|
||||
}
|
||||
|
||||
const WorkspaceDock: React.FC<Props> = ({ panels, active, badges, onSelect }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [rowW, setRowW] = useState(0);
|
||||
const n = Math.max(1, panels.length);
|
||||
const idx = Math.max(0, panels.indexOf(active));
|
||||
const slot = rowW / n;
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => setRowW(e.nativeEvent.layout.width);
|
||||
|
||||
const indicatorStyle = useAnimatedStyle(() => ({
|
||||
width: slot,
|
||||
transform: [{ translateX: withTiming(slot * idx, { duration: 200 }) }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={[styles.dock, { paddingBottom: Math.max(insets.bottom, 6) }]}>
|
||||
<View style={styles.row} onLayout={onLayout}>
|
||||
{rowW > 0 && <Animated.View style={[styles.indicator, indicatorStyle]} pointerEvents="none" />}
|
||||
{panels.map((id) => {
|
||||
const meta = TILE_META[id];
|
||||
const isActive = id === active;
|
||||
const badge = badges?.[id];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={id}
|
||||
style={styles.item}
|
||||
onPress={() => onSelect(id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View>
|
||||
<Text style={[styles.icon, isActive && styles.iconActive]}>{meta.icon}</Text>
|
||||
{!!badge && <View style={[styles.badge, { backgroundColor: badge }]} />}
|
||||
</View>
|
||||
<Text style={[styles.label, isActive && styles.labelActive]} numberOfLines={1}>
|
||||
{meta.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dock: {
|
||||
backgroundColor: '#0B0B18',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#1E1E2E',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
height: 58,
|
||||
position: 'relative',
|
||||
},
|
||||
indicator: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: 3,
|
||||
backgroundColor: '#0096FF',
|
||||
borderBottomLeftRadius: 3,
|
||||
borderBottomRightRadius: 3,
|
||||
},
|
||||
item: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
icon: { fontSize: 22, opacity: 0.55 },
|
||||
iconActive: { opacity: 1 },
|
||||
label: { color: '#6A6A85', fontSize: 10, fontWeight: '600' },
|
||||
labelActive: { color: '#0096FF' },
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -6,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#0B0B18',
|
||||
},
|
||||
});
|
||||
|
||||
export default WorkspaceDock;
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* WorkspaceScreen — Screen-Wrapper fuer den Workspace-Canvas.
|
||||
* WorkspaceScreen — Screen-Wrapper fuer die Workbench.
|
||||
*
|
||||
* Buendelt die Signale, die entscheiden welche Kacheln sichtbar sind:
|
||||
* - projectFocus: aktives Projekt + dessen kind ('code'|'chat')
|
||||
* - codeFile: kam schon eine Code-Datei rein? (Live-Reveal des Editors)
|
||||
* - desktop: ist ein QEMU-Desktop verfuegbar? (Live-Reveal der VNC-Kachel)
|
||||
* Kompakt-Modus → klassischer Vollbild-Chat (wie vor dem Umbau).
|
||||
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
|
||||
*
|
||||
* Ersetzt den bisherigen Chat-Tab: die ChatScreen lebt als Kachel im Canvas,
|
||||
* bleibt aber genau eine Instanz.
|
||||
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
|
||||
* Desktop gruen, wenn eine VM verbunden ist.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
@@ -16,8 +14,10 @@ import codeFile from '../services/codeFile';
|
||||
import desktop from '../services/desktop';
|
||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
||||
import ChatScreen from '../screens/ChatScreen';
|
||||
import { TileId, TILE_ORDER } from './layout';
|
||||
import WorkspaceCanvas from './WorkspaceCanvas';
|
||||
import { TileId } from './layout';
|
||||
import WorkspaceDeck from './WorkspaceDeck';
|
||||
|
||||
const COCKPIT_PANELS: TileId[] = ['chat', 'editor', 'vnc'];
|
||||
|
||||
const WorkspaceScreen: React.FC = () => {
|
||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
||||
@@ -47,29 +47,18 @@ const WorkspaceScreen: React.FC = () => {
|
||||
return unsub;
|
||||
}, [pid, kind]);
|
||||
|
||||
// Im Cockpit immer alle Kacheln zeigen (Editor/Desktop/Vorschau als
|
||||
// Platzhalter, bis ein Code-Projekt/VM aktiv ist) — sonst „fehlen" sie
|
||||
// gefuehlt. isCode blendet nur die Untertitel-Info um.
|
||||
const visibleTiles: TileId[] = TILE_ORDER;
|
||||
const isCode = kind === 'code' || hasCode || hasDesktop;
|
||||
|
||||
const subtitles = useMemo(() => {
|
||||
const fileCount = codeFile.getFiles(pid).length;
|
||||
return {
|
||||
chat: pid ? projectFocus.getProjectName(pid) : 'Hauptchat',
|
||||
editor: isCode ? `${fileCount} Datei${fileCount === 1 ? '' : 'en'}` : 'kein Code-Projekt',
|
||||
vnc: hasDesktop ? 'verbunden' : 'kein Desktop',
|
||||
preview: undefined,
|
||||
} as Partial<Record<TileId, string>>;
|
||||
}, [pid, focus.projectNameById, isCode, hasCode, hasDesktop]);
|
||||
const badges = useMemo(() => ({
|
||||
editor: hasCode ? '#0096FF' : undefined,
|
||||
vnc: hasDesktop ? '#34C759' : undefined,
|
||||
} as Partial<Record<TileId, string>>), [hasCode, hasDesktop]);
|
||||
|
||||
// Kompakt-Ansicht: klassischer Vollbild-Chat, exakt wie vor dem Umbau.
|
||||
if (mode === 'compact') {
|
||||
return <ChatScreen />;
|
||||
}
|
||||
|
||||
// Cockpit: zoombarer Kachel-Desktop.
|
||||
return <WorkspaceCanvas projectId={pid} visibleTiles={visibleTiles} subtitles={subtitles} />;
|
||||
// Cockpit: Workbench mit Dock.
|
||||
return <WorkspaceDeck projectId={pid} panels={COCKPIT_PANELS} badges={badges} />;
|
||||
};
|
||||
|
||||
export default WorkspaceScreen;
|
||||
|
||||
@@ -76,10 +76,20 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
||||
};
|
||||
|
||||
var msg=document.getElementById('msg');
|
||||
// noVNC per ESM vom CDN laden.
|
||||
|
||||
// Verstecktes Eingabefeld → Software-Tastatur des Handys tippt in die VM.
|
||||
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;';
|
||||
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};
|
||||
|
||||
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
|
||||
var RFB = mod.default;
|
||||
var rfb = new RFB(document.getElementById('screen'), 'ws://aria-vnc/', {});
|
||||
var fit=true;
|
||||
rfb.scaleViewport = true;
|
||||
rfb.clipViewport = false;
|
||||
rfb.addEventListener('connect', function(){ msg.style.display='none'; post({event:'vnc_state', state:'connected'}); });
|
||||
@@ -88,6 +98,24 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
||||
post({event:'vnc_state', state:'disconnected'});
|
||||
});
|
||||
window.__rfb = rfb;
|
||||
|
||||
// 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(_){} }
|
||||
kbd.addEventListener('keydown', function(e){
|
||||
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
|
||||
});
|
||||
kbd.addEventListener('input', function(){
|
||||
var v=kbd.value; for(var i=0;i<v.length;i++){ tap(v.charCodeAt(i)); } kbd.value='';
|
||||
});
|
||||
|
||||
// Steuerungs-API fuer die App (per injectJavaScript).
|
||||
window.ariaVncCtl = {
|
||||
focusKeyboard: function(){ try{ 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}); }
|
||||
};
|
||||
|
||||
post({event:'ready'});
|
||||
}).catch(function(err){
|
||||
msg.textContent='noVNC konnte nicht geladen werden (Internet?)';
|
||||
|
||||
@@ -1,95 +1,14 @@
|
||||
/**
|
||||
* layout — Kachel-Geometrie + Kamera-Mathematik fuer den Workspace-Canvas.
|
||||
*
|
||||
* Welt-Koordinaten = Pixel bei Scale 1. Kacheln liegen auf einem festen 2x2-
|
||||
* Raster. Die "Welt-View" (Animated.View) ist exakt die Bounding-Box der gerade
|
||||
* sichtbaren Kacheln; Kinder werden relativ zu deren Ursprung positioniert.
|
||||
*
|
||||
* RN 0.73 kennt noch kein transformOrigin — Scale dreht um die View-MITTE.
|
||||
* Alle Kamera-Formeln rechnen deshalb mit Center-Origin:
|
||||
* screen = center + scale*(p - center) + translate
|
||||
* wobei center = (worldW/2, worldH/2) (die Welt-View sitzt bei screen 0,0).
|
||||
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
|
||||
*/
|
||||
|
||||
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
|
||||
|
||||
export interface TileRect { x: number; y: number; w: number; h: number; }
|
||||
|
||||
export interface TileDef { id: TileId; title: string; icon: string; }
|
||||
|
||||
// Feste Kachelgroesse im Welt-Raster (Pixel bei Scale 1).
|
||||
const TILE_W = 1100;
|
||||
const TILE_H = 1500;
|
||||
const GAP = 140;
|
||||
|
||||
/** Absolute Welt-Rects pro Kachel (2x2-Raster). */
|
||||
export const TILE_RECTS: Record<TileId, TileRect> = {
|
||||
chat: { x: 0, y: 0, w: TILE_W, h: TILE_H },
|
||||
editor: { x: TILE_W + GAP, y: 0, w: TILE_W, h: TILE_H },
|
||||
vnc: { x: 0, y: TILE_H + GAP, w: TILE_W, h: TILE_H },
|
||||
preview: { x: TILE_W + GAP, y: TILE_H + GAP, w: TILE_W, h: TILE_H },
|
||||
};
|
||||
export interface TileDef { id: TileId; title: string; icon: string }
|
||||
|
||||
export const TILE_META: Record<TileId, TileDef> = {
|
||||
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
||||
editor: { id: 'editor', title: 'Editor', icon: '📝' },
|
||||
editor: { id: 'editor', title: 'Code', icon: '📝' },
|
||||
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
||||
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
||||
};
|
||||
|
||||
// Reihenfolge fuer stabiles Rendering.
|
||||
export const TILE_ORDER: TileId[] = ['chat', 'editor', 'vnc', 'preview'];
|
||||
|
||||
export interface VisibilitySignals {
|
||||
kind: 'code' | 'chat';
|
||||
hasCode: boolean; // schon ein code_file empfangen
|
||||
hasDesktop: boolean; // Desktop verfuegbar gemeldet
|
||||
}
|
||||
|
||||
/** Welche Kacheln sind fuer den aktuellen Kontext sichtbar?
|
||||
* Chat ist immer da; Code-Projekt (explizit ODER durch ein Live-Signal)
|
||||
* blendet Editor/Desktop/Vorschau ein. */
|
||||
export function visibleTilesFor(sig: VisibilitySignals): TileId[] {
|
||||
const isCode = sig.kind === 'code' || sig.hasCode || sig.hasDesktop;
|
||||
if (!isCode) return ['chat'];
|
||||
return ['chat', 'editor', 'vnc', 'preview'];
|
||||
}
|
||||
|
||||
/** Bounding-Box mehrerer Kacheln. */
|
||||
export function boundsOf(ids: TileId[]): TileRect {
|
||||
if (ids.length === 0) return { x: 0, y: 0, w: TILE_W, h: TILE_H };
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const id of ids) {
|
||||
const r = TILE_RECTS[id];
|
||||
minX = Math.min(minX, r.x);
|
||||
minY = Math.min(minY, r.y);
|
||||
maxX = Math.max(maxX, r.x + r.w);
|
||||
maxY = Math.max(maxY, r.y + r.h);
|
||||
}
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}
|
||||
|
||||
/** Rect in Welt-View-lokale Koordinaten (relativ zur Bounds-Ecke) umrechnen. */
|
||||
export function toLocal(rect: TileRect, bounds: TileRect): TileRect {
|
||||
return { x: rect.x - bounds.x, y: rect.y - bounds.y, w: rect.w, h: rect.h };
|
||||
}
|
||||
|
||||
export interface Camera { scale: number; tx: number; ty: number; }
|
||||
|
||||
/** Kamera, die ein (lokales) Rect bildschirmfuellend zeigt (Fokus-Modus). */
|
||||
export function focusCamera(localRect: TileRect, worldW: number, worldH: number, vw: number, vh: number): Camera {
|
||||
const s = Math.min(vw / localRect.w, vh / localRect.h);
|
||||
const pcx = localRect.x + localRect.w / 2;
|
||||
const pcy = localRect.y + localRect.h / 2;
|
||||
const tx = vw / 2 - worldW / 2 - s * (pcx - worldW / 2);
|
||||
const ty = vh / 2 - worldH / 2 - s * (pcy - worldH / 2);
|
||||
return { scale: s, tx, ty };
|
||||
}
|
||||
|
||||
/** Kamera, die die gesamte Welt zentriert einpasst (Uebersicht). */
|
||||
export function overviewCamera(worldW: number, worldH: number, vw: number, vh: number, pad = 0.86): Camera {
|
||||
const s = Math.min(vw / worldW, vh / worldH) * pad;
|
||||
const tx = vw / 2 - worldW / 2;
|
||||
const ty = vh / 2 - worldH / 2;
|
||||
return { scale: s, tx, ty };
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* PreviewTile — zeigt den letzten Screenshot/Vorschau-Frame eines Code-Projekts
|
||||
* (z. B. aria-vm screenshot). Fuellt sich, sobald ARIA ein Bild in den
|
||||
* Vorschau-Kanal legt; bis dahin ein ruhiger Platzhalter.
|
||||
*
|
||||
* (Screenshot-Anbindung folgt mit dem QEMU-Track; hier zunaechst die Kachel.)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Image, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
interface Props {
|
||||
imageUri?: string;
|
||||
}
|
||||
|
||||
const PreviewTile: React.FC<Props> = ({ imageUri }) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{imageUri ? (
|
||||
<Image source={{ uri: imageUri }} style={styles.image} resizeMode="contain" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.icon}>🖼️</Text>
|
||||
<Text style={styles.text}>Noch keine Vorschau</Text>
|
||||
<Text style={styles.sub}>Screenshots der VM erscheinen hier.</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
|
||||
image: { width: '100%', height: '100%' },
|
||||
icon: { fontSize: 64, marginBottom: 16 },
|
||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||
});
|
||||
|
||||
export default PreviewTile;
|
||||
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
||||
*
|
||||
* Nur im Fokus aktiv: dann wird die WebView gemountet, bei 'ready' der
|
||||
* RVS-VNC-Tunnel geoeffnet (desktop.openVnc). Server-Bytes (vnc_data) werden in
|
||||
* die WebView injiziert, RFB-Bytes der WebView (vnc_send) gehen als vnc_input
|
||||
* zurueck. Verlaesst man die Kachel, wird der Tunnel geschlossen (die VM laeuft
|
||||
* auf dem Host weiter).
|
||||
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
|
||||
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Eine kleine Steuerungs-Leiste macht
|
||||
* die VM auf dem Handy bedienbar: Tastatur einblenden (tippt in die VM),
|
||||
* Strg-Alt-Entf, und Fit ↔ 1:1 umschalten.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||
import desktop from '../../services/desktop';
|
||||
import { NOVNC_HTML } from '../assets/novncHtml';
|
||||
@@ -24,23 +23,24 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
||||
const unsubDataRef = useRef<null | (() => void)>(null);
|
||||
|
||||
// Aufraeumen: Tunnel zu + Daten-Abo weg.
|
||||
const teardown = useCallback(() => {
|
||||
if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; }
|
||||
desktop.closeVnc();
|
||||
}, []);
|
||||
|
||||
// Fokus verloren / Unmount → Tunnel schliessen.
|
||||
useEffect(() => {
|
||||
if (!focused) { teardown(); setStatus('idle'); }
|
||||
return () => teardown();
|
||||
}, [focused, teardown]);
|
||||
|
||||
const ctl = useCallback((fn: string) => {
|
||||
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
|
||||
}, []);
|
||||
|
||||
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
||||
let m: any;
|
||||
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||
if (m.event === 'ready') {
|
||||
// WebView + RFB bereit → Tunnel oeffnen und Server-Bytes einspeisen.
|
||||
setStatus('connecting');
|
||||
unsubDataRef.current = desktop.onVncData((b64) => {
|
||||
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
|
||||
@@ -62,11 +62,12 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.icon}>🖥️</Text>
|
||||
<Text style={styles.text}>Desktop</Text>
|
||||
<Text style={styles.sub}>Antippen zum Verbinden</Text>
|
||||
<Text style={styles.sub}>Panel öffnen zum Verbinden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const connected = status === 'connected';
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<WebView
|
||||
@@ -79,8 +80,25 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
||||
domStorageEnabled
|
||||
mixedContentMode="always"
|
||||
androidLayerType="hardware"
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
/>
|
||||
{status !== 'connected' && (
|
||||
|
||||
{/* Steuerungs-Leiste — nur wenn verbunden */}
|
||||
{connected && (
|
||||
<View style={styles.ctlBar}>
|
||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('focusKeyboard')} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>⌨</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('cad')} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlTextSmall}>Strg+Alt+Entf</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
|
||||
<Text style={styles.ctlText}>⤢</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!connected && (
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<Text style={styles.overlayText}>
|
||||
{status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''}
|
||||
@@ -98,7 +116,27 @@ const styles = StyleSheet.create({
|
||||
icon: { fontSize: 64, marginBottom: 16 },
|
||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||
overlay: { position: 'absolute', top: 10, left: 0, right: 0, alignItems: 'center' },
|
||||
ctlBar: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
},
|
||||
ctlBtn: {
|
||||
backgroundColor: 'rgba(18,18,42,0.9)',
|
||||
borderColor: '#2A2A3E',
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 7,
|
||||
minWidth: 38,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
|
||||
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
|
||||
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' },
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user