new version full rebuild with claude opus 4.6 and own rdp daemon

This commit is contained in:
felixg
2026-02-28 21:19:52 +01:00
parent fac33c27b4
commit 6e9956bce9
19 changed files with 2185 additions and 326 deletions

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useRef, useState } from 'react';
import { Layout } from 'antd';
import { TopNav } from '../Nav/TopNav';
import { ConnectionTree } from '../Sidebar/ConnectionTree';
import { ConnectionProperties } from '../Sidebar/ConnectionProperties';
import { SessionTabs } from '../Tabs/SessionTabs';
const { Content } = Layout;
@@ -10,10 +11,17 @@ const MIN_SIDEBAR = 180;
const MAX_SIDEBAR = 600;
const DEFAULT_SIDEBAR = 260;
const MIN_PROPERTIES_HEIGHT = 150;
const DEFAULT_TREE_FRACTION = 0.6;
export const MainLayout: React.FC = () => {
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR);
const [treeFraction, setTreeFraction] = useState(DEFAULT_TREE_FRACTION);
const isResizing = useRef(false);
const isVResizing = useRef(false);
const sidebarRef = useRef<HTMLDivElement>(null);
// Horizontal sidebar resize
const onMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isResizing.current = true;
@@ -38,6 +46,36 @@ export const MainLayout: React.FC = () => {
document.addEventListener('mouseup', onMouseUp);
}, []);
// Vertical splitter between tree and properties
const onVMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isVResizing.current = true;
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
const onMouseMove = (me: MouseEvent) => {
if (!isVResizing.current || !sidebarRef.current) return;
const rect = sidebarRef.current.getBoundingClientRect();
const totalHeight = rect.height;
const relativeY = me.clientY - rect.top;
const propertiesHeight = totalHeight - relativeY;
// Enforce min heights
if (propertiesHeight < MIN_PROPERTIES_HEIGHT || relativeY < MIN_PROPERTIES_HEIGHT) return;
setTreeFraction(relativeY / totalHeight);
};
const onMouseUp = () => {
isVResizing.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}, []);
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<TopNav />
@@ -45,6 +83,7 @@ export const MainLayout: React.FC = () => {
<div style={{ display: 'flex', flexDirection: 'row', flex: 1, overflow: 'hidden' }}>
{/* Resizable sidebar */}
<div
ref={sidebarRef}
style={{
width: sidebarWidth,
flexShrink: 0,
@@ -55,7 +94,36 @@ export const MainLayout: React.FC = () => {
background: '#fff',
}}
>
<ConnectionTree />
{/* Connection tree (top) */}
<div style={{ flex: `0 0 ${treeFraction * 100}%`, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<ConnectionTree />
</div>
{/* Vertical splitter */}
<div
onMouseDown={onVMouseDown}
style={{
height: 4,
cursor: 'row-resize',
background: 'transparent',
flexShrink: 0,
zIndex: 10,
borderTop: '1px solid #f0f0f0',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.background = '#1677ff40';
}}
onMouseLeave={(e) => {
if (!isVResizing.current)
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
}}
/>
{/* Connection properties (bottom) */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<ConnectionProperties />
</div>
</div>
{/* Resize handle */}

View File

@@ -0,0 +1,188 @@
import React, { useEffect, useState } from 'react';
import {
Form,
Input,
InputNumber,
Select,
Button,
Tag,
Typography,
message,
Divider,
Row,
Col,
} from 'antd';
import { useStore } from '../../store';
import { apiConnectionUpdate } from '../../api/client';
import { ConnectionFormValues, Folder } from '../../types';
const { Option } = Select;
const { TextArea } = Input;
const buildFolderOptions = (
allFolders: Folder[],
parentId: string | null = null,
depth = 0
): React.ReactNode[] => {
return allFolders
.filter((f) => f.parentId === parentId)
.flatMap((f) => [
<Option key={f.id} value={f.id}>
{'\u00a0\u00a0'.repeat(depth)}
{depth > 0 ? '└ ' : ''}
{f.name}
</Option>,
...buildFolderOptions(allFolders, f.id, depth + 1),
]);
};
export const ConnectionProperties: React.FC = () => {
const selectedConnectionId = useStore((s) => s.selectedConnectionId);
const connections = useStore((s) => s.connections);
const folders = useStore((s) => s.folders);
const setConnections = useStore((s) => s.setConnections);
const [form] = Form.useForm<ConnectionFormValues>();
const [saving, setSaving] = useState(false);
const connection = connections.find((c) => c.id === selectedConnectionId) ?? null;
const protocol = Form.useWatch('protocol', form);
useEffect(() => {
if (connection) {
form.setFieldsValue({
name: connection.name,
host: connection.host,
port: connection.port,
protocol: connection.protocol,
username: connection.username,
privateKey: connection.privateKey ?? undefined,
domain: connection.domain ?? undefined,
osType: connection.osType ?? undefined,
notes: connection.notes ?? undefined,
folderId: connection.folderId ?? null,
});
} else {
form.resetFields();
}
}, [connection, form]);
const handleSave = async () => {
if (!connection) return;
try {
const values = await form.validateFields();
setSaving(true);
const res = await apiConnectionUpdate(connection.id, values);
// Update connection in store
setConnections(
connections.map((c) => (c.id === connection.id ? { ...c, ...res.data } : c))
);
message.success('Connection updated');
} catch {
message.error('Failed to save connection');
} finally {
setSaving(false);
}
};
if (!connection) {
return (
<div
style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
>
<Typography.Text type="secondary">
Select a connection to view its properties
</Typography.Text>
</div>
);
}
return (
<div style={{ height: '100%', overflow: 'auto', padding: '8px 12px' }}>
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<Typography.Text strong style={{ fontSize: 13 }}>
{connection.name}
</Typography.Text>
<Tag color={connection.protocol === 'rdp' ? 'blue' : 'green'}>
{connection.protocol.toUpperCase()}
</Tag>
</div>
<Form form={form} layout="vertical" size="small" requiredMark={false}>
<Row gutter={8}>
<Col flex={1}>
<Form.Item label="Host" name="host" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
<Col style={{ width: 90 }}>
<Form.Item label="Port" name="port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item label="Protocol" name="protocol" rules={[{ required: true }]}>
<Select
onChange={(v: 'ssh' | 'rdp') => form.setFieldValue('port', v === 'ssh' ? 22 : 3389)}
>
<Option value="ssh">SSH</Option>
<Option value="rdp">RDP</Option>
</Select>
</Form.Item>
<Form.Item label="Username" name="username" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="Password" name="password" extra="Leave blank to keep current">
<Input.Password placeholder="••••••••" autoComplete="new-password" />
</Form.Item>
{protocol === 'ssh' && (
<Form.Item label="Private Key" name="privateKey">
<TextArea rows={3} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" />
</Form.Item>
)}
{protocol === 'rdp' && (
<Form.Item label="Domain" name="domain">
<Input placeholder="CORP" />
</Form.Item>
)}
<Divider style={{ margin: '4px 0 8px' }} />
<Form.Item label="Name" name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="OS Type" name="osType">
<Select allowClear placeholder="Select OS">
<Option value="linux">Linux</Option>
<Option value="windows">Windows</Option>
</Select>
</Form.Item>
<Form.Item label="Folder" name="folderId">
<Select allowClear placeholder="Root (no folder)">
{buildFolderOptions(folders)}
</Select>
</Form.Item>
<Form.Item label="Notes" name="notes">
<TextArea rows={2} placeholder="Optional notes…" />
</Form.Item>
<Button type="primary" block onClick={handleSave} loading={saving}>
Save
</Button>
</Form>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import {
Tree,
Button,
@@ -21,6 +21,7 @@ import {
EditOutlined,
DeleteOutlined,
FolderAddOutlined,
SearchOutlined,
} from '@ant-design/icons';
import { useStore } from '../../store';
import {
@@ -85,6 +86,8 @@ export const ConnectionTree: React.FC = () => {
const setFolders = useStore((s) => s.setFolders);
const setConnections = useStore((s) => s.setConnections);
const openSession = useStore((s) => s.openSession);
const selectedConnectionId = useStore((s) => s.selectedConnectionId);
const setSelectedConnection = useStore((s) => s.setSelectedConnection);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
@@ -92,6 +95,65 @@ export const ConnectionTree: React.FC = () => {
const [newFolderParentId, setNewFolderParentId] = useState<string | null>(null);
const [addingFolder, setAddingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
const searchInputRef = useRef<ReturnType<typeof Input.Search>>(null);
// Find all connections matching the search query
const searchMatches = useMemo(() => {
if (!searchQuery.trim()) return [];
const q = searchQuery.toLowerCase();
return connections.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.host.toLowerCase().includes(q)
);
}, [searchQuery, connections]);
// Auto-select the first match when query or matches change
useEffect(() => {
if (searchMatches.length > 0) {
const idx = Math.min(searchMatchIndex, searchMatches.length - 1);
setSelectedConnection(searchMatches[idx].id);
setSearchMatchIndex(idx);
}
}, [searchMatches, searchMatchIndex, setSelectedConnection]);
// Compute expanded folder keys so matched connections inside folders are visible
const searchExpandedKeys = useMemo(() => {
if (!searchQuery.trim() || searchMatches.length === 0) return undefined;
const keys = new Set<string>();
const folderMap = new Map(folders.map((f) => [f.id, f]));
for (const conn of searchMatches) {
let fid = conn.folderId;
while (fid) {
keys.add(`folder-${fid}`);
fid = folderMap.get(fid)?.parentId ?? null;
}
}
return [...keys];
}, [searchQuery, searchMatches, folders]);
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (searchMatches.length > 0)
setSearchMatchIndex((i) => (i + 1) % searchMatches.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (searchMatches.length > 0)
setSearchMatchIndex((i) => (i - 1 + searchMatches.length) % searchMatches.length);
} else if (e.key === 'Enter') {
e.preventDefault();
if (searchMatches.length > 0) {
const idx = Math.min(searchMatchIndex, searchMatches.length - 1);
openSession(searchMatches[idx]);
}
} else if (e.key === 'Escape') {
setSearchQuery('');
setSearchMatchIndex(0);
}
};
const refresh = useCallback(async () => {
setLoading(true);
@@ -135,6 +197,16 @@ export const ConnectionTree: React.FC = () => {
}
};
// --- Single-click: select connection for properties panel ---
const onSelect = (selectedKeys: React.Key[], info: { node: DataNode }) => {
const ext = info.node as ExtendedDataNode;
if (ext.itemData.type === 'connection') {
setSelectedConnection(ext.itemData.connection!.id);
} else {
setSelectedConnection(null);
}
};
// --- Double-click: open session ---
const onDoubleClick = (_: React.MouseEvent, node: DataNode) => {
const ext = node as ExtendedDataNode;
@@ -311,6 +383,22 @@ export const ConnectionTree: React.FC = () => {
<Tooltip title="Refresh">
<Button size="small" icon={<ReloadOutlined />} loading={loading} onClick={refresh} />
</Tooltip>
<Input
ref={searchInputRef as React.Ref<any>}
size="small"
placeholder="Search…"
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
allowClear
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setSearchMatchIndex(0); }}
onKeyDown={handleSearchKeyDown}
style={{ flex: 1, minWidth: 0 }}
suffix={searchQuery && searchMatches.length > 0 ? (
<span style={{ fontSize: 11, color: '#999', whiteSpace: 'nowrap' }}>
{Math.min(searchMatchIndex + 1, searchMatches.length)}/{searchMatches.length}
</span>
) : undefined}
/>
</div>
{/* Inline folder name input */}
@@ -340,6 +428,9 @@ export const ConnectionTree: React.FC = () => {
draggable={{ icon: false }}
blockNode
showIcon
selectedKeys={selectedConnectionId ? [`connection-${selectedConnectionId}`] : []}
{...(searchExpandedKeys ? { expandedKeys: searchExpandedKeys } : {})}
onSelect={onSelect}
onDrop={onDrop}
onDoubleClick={onDoubleClick}
titleRender={titleRender}

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Alert, Spin } from 'antd';
import Guacamole from 'guacamole-common-js';
import { useStore } from '../../store';
import { Session } from '../../types';
@@ -10,95 +9,300 @@ interface Props {
type Status = 'connecting' | 'connected' | 'disconnected' | 'error';
// NOTE: Do NOT include query params here. Guacamole.WebSocketTunnel always
// appends "?" + connectData to the URL, so auth params must go via connect().
function getWsUrl(connectionId: string): string {
// ---------------------------------------------------------------------------
// X11 KeySym lookup table — maps browser event.code / event.key to X11 keysyms
// ---------------------------------------------------------------------------
const KEY_CODE_TO_KEYSYM: Record<string, number> = {
// Letters (lowercase keysyms — X11 uses lowercase for letter keys)
KeyA: 0x61, KeyB: 0x62, KeyC: 0x63, KeyD: 0x64, KeyE: 0x65,
KeyF: 0x66, KeyG: 0x67, KeyH: 0x68, KeyI: 0x69, KeyJ: 0x6a,
KeyK: 0x6b, KeyL: 0x6c, KeyM: 0x6d, KeyN: 0x6e, KeyO: 0x6f,
KeyP: 0x70, KeyQ: 0x71, KeyR: 0x72, KeyS: 0x73, KeyT: 0x74,
KeyU: 0x75, KeyV: 0x76, KeyW: 0x77, KeyX: 0x78, KeyY: 0x79,
KeyZ: 0x7a,
// Digits
Digit0: 0x30, Digit1: 0x31, Digit2: 0x32, Digit3: 0x33, Digit4: 0x34,
Digit5: 0x35, Digit6: 0x36, Digit7: 0x37, Digit8: 0x38, Digit9: 0x39,
// Function keys
F1: 0xffbe, F2: 0xffbf, F3: 0xffc0, F4: 0xffc1, F5: 0xffc2,
F6: 0xffc3, F7: 0xffc4, F8: 0xffc5, F9: 0xffc6, F10: 0xffc7,
F11: 0xffc8, F12: 0xffc9,
// Navigation
ArrowUp: 0xff52, ArrowDown: 0xff54, ArrowLeft: 0xff51, ArrowRight: 0xff53,
Home: 0xff50, End: 0xff57, PageUp: 0xff55, PageDown: 0xff56,
Insert: 0xff63,
// Editing
Backspace: 0xff08, Delete: 0xffff, Enter: 0xff0d, NumpadEnter: 0xff0d,
Tab: 0xff09, Escape: 0xff1b, Space: 0x20,
// Modifiers
ShiftLeft: 0xffe1, ShiftRight: 0xffe2,
ControlLeft: 0xffe3, ControlRight: 0xffe4,
AltLeft: 0xffe9, AltRight: 0xffea,
MetaLeft: 0xffeb, MetaRight: 0xffec,
CapsLock: 0xffe5, NumLock: 0xff7f, ScrollLock: 0xff14,
// Punctuation / symbols
Minus: 0x2d, Equal: 0x3d, BracketLeft: 0x5b, BracketRight: 0x5d,
Backslash: 0x5c, Semicolon: 0x3b, Quote: 0x27, Backquote: 0x60,
Comma: 0x2c, Period: 0x2e, Slash: 0x2f,
// Numpad
Numpad0: 0xffb0, Numpad1: 0xffb1, Numpad2: 0xffb2, Numpad3: 0xffb3,
Numpad4: 0xffb4, Numpad5: 0xffb5, Numpad6: 0xffb6, Numpad7: 0xffb7,
Numpad8: 0xffb8, Numpad9: 0xffb9,
NumpadDecimal: 0xffae, NumpadAdd: 0xffab, NumpadSubtract: 0xffad,
NumpadMultiply: 0xffaa, NumpadDivide: 0xffaf,
// Misc
PrintScreen: 0xff61, Pause: 0xff13, ContextMenu: 0xff67,
};
// Shifted symbol mapping — when Shift is held, browser sends the symbol as event.key
const SHIFTED_KEY_TO_KEYSYM: Record<string, number> = {
'!': 0x21, '@': 0x40, '#': 0x23, '$': 0x24, '%': 0x25,
'^': 0x5e, '&': 0x26, '*': 0x2a, '(': 0x28, ')': 0x29,
'_': 0x5f, '+': 0x2b, '{': 0x7b, '}': 0x7d, '|': 0x7c,
':': 0x3a, '"': 0x22, '~': 0x7e, '<': 0x3c, '>': 0x3e,
'?': 0x3f,
};
function getKeySym(e: KeyboardEvent): number | null {
// Try code-based lookup first (position-independent)
const codeSym = KEY_CODE_TO_KEYSYM[e.code];
if (codeSym !== undefined) {
// For letter keys, return uppercase keysym if shift is held
if (e.code.startsWith('Key') && e.shiftKey) {
return codeSym - 0x20; // lowercase → uppercase in ASCII/X11
}
return codeSym;
}
// Try shifted symbol lookup
const shiftedSym = SHIFTED_KEY_TO_KEYSYM[e.key];
if (shiftedSym !== undefined) return shiftedSym;
// Single printable character — use char code as keysym (works for ASCII)
if (e.key.length === 1) {
return e.key.charCodeAt(0);
}
return null;
}
function getWsUrl(connectionId: string, token: string): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
return `${proto}//${host}/ws/rdp/${connectionId}`;
return `${proto}//${host}/ws/rdp/${connectionId}?token=${encodeURIComponent(token)}`;
}
export const RdpTab: React.FC<Props> = ({ session }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const token = useStore((s) => s.token) ?? '';
const [status, setStatus] = useState<Status>('connecting');
const [errorMsg, setErrorMsg] = useState<string>('');
// Scale factor for translating mouse coordinates
const scaleRef = useRef({ x: 1, y: 1 });
const sendJson = useCallback((msg: object) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}, []);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!container) return;
if (!canvas || !container) return;
setStatus('connecting');
setErrorMsg('');
const url = getWsUrl(session.connection.id);
const tunnel = new Guacamole.WebSocketTunnel(url);
const client = new Guacamole.Client(tunnel);
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Mount the Guacamole display canvas
const displayEl = client.getDisplay().getElement();
displayEl.style.cursor = 'default';
container.appendChild(displayEl);
const url = getWsUrl(session.connection.id, token);
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
// Mouse input
const mouse = new Guacamole.Mouse(displayEl);
const sendMouse = (mouseState: Guacamole.Mouse.State) =>
client.sendMouseState(mouseState, true);
mouse.onmousedown = sendMouse;
mouse.onmouseup = sendMouse;
mouse.onmousemove = sendMouse;
// Track the remote resolution for coordinate mapping
let remoteWidth = 1280;
let remoteHeight = 720;
// Keyboard input
const keyboard = new Guacamole.Keyboard(document);
keyboard.onkeydown = (keysym: number) => client.sendKeyEvent(1, keysym);
keyboard.onkeyup = (keysym: number) => client.sendKeyEvent(0, keysym);
// Scale display to fit container
const fitDisplay = () => {
const display = client.getDisplay();
if (!container || display.getWidth() === 0) return;
const scaleX = container.clientWidth / display.getWidth();
const scaleY = container.clientHeight / display.getHeight();
display.scale(Math.min(scaleX, scaleY));
};
client.getDisplay().onresize = () => {
setStatus('connected');
fitDisplay();
};
const resizeObserver = new ResizeObserver(fitDisplay);
resizeObserver.observe(container);
tunnel.onerror = (status: Guacamole.Status) => {
console.error('Guacamole tunnel error:', status.message);
setStatus('error');
setErrorMsg(`Tunnel error: ${status.message ?? 'unknown'}`);
};
client.onerror = (error: Guacamole.Status) => {
console.error('Guacamole client error:', error.message);
setStatus('error');
setErrorMsg(`Client error: ${error.message ?? 'unknown'}`);
};
// Connect — pass token as query param via connect(data).
// WebSocketTunnel appends "?" + data to the base URL, so auth goes here.
client.connect(`token=${encodeURIComponent(token)}`);
return () => {
keyboard.onkeydown = null;
keyboard.onkeyup = null;
resizeObserver.disconnect();
client.disconnect();
if (container.contains(displayEl)) {
container.removeChild(displayEl);
const updateScale = () => {
if (canvas.clientWidth > 0 && canvas.clientHeight > 0) {
scaleRef.current = {
x: remoteWidth / canvas.clientWidth,
y: remoteHeight / canvas.clientHeight,
};
}
};
}, [session.connection.id, token]);
ws.onopen = () => {
// The backend handles the connect message to rdpd — we just need
// the WebSocket open. No client-side connect message needed.
};
ws.onmessage = async (event) => {
if (event.data instanceof ArrayBuffer) {
// Binary frame — JPEG image
const blob = new Blob([event.data], { type: 'image/jpeg' });
try {
const bitmap = await createImageBitmap(blob);
// Update canvas size if the frame size changed
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
canvas.width = bitmap.width;
canvas.height = bitmap.height;
remoteWidth = bitmap.width;
remoteHeight = bitmap.height;
updateScale();
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
} catch (err) {
console.warn('Failed to decode JPEG frame:', err);
}
} else {
// Text frame — JSON control message
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'connected':
setStatus('connected');
break;
case 'disconnected':
setStatus('disconnected');
break;
case 'error':
setStatus('error');
setErrorMsg(msg.message || 'Unknown error');
break;
case 'clipboardRead':
// Write remote clipboard content to local clipboard
if (msg.text && navigator.clipboard) {
navigator.clipboard.writeText(msg.text).catch(() => {
// Clipboard write may fail without user gesture
});
}
break;
}
} catch (err) {
console.warn('Failed to parse control message:', err);
}
}
};
ws.onerror = () => {
setStatus('error');
setErrorMsg('WebSocket connection error');
};
ws.onclose = () => {
if (status !== 'error') {
setStatus('disconnected');
}
};
// --- Mouse event handlers ---
const translateCoords = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
return {
x: Math.round((e.clientX - rect.left) * scaleRef.current.x),
y: Math.round((e.clientY - rect.top) * scaleRef.current.y),
};
};
const onMouseMove = (e: MouseEvent) => {
const { x, y } = translateCoords(e);
sendJson({ type: 'mouseMove', x, y });
};
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
canvas.focus();
const { x, y } = translateCoords(e);
// Browser button: 0=left, 1=middle, 2=right → X11: 1, 2, 3
sendJson({ type: 'mouseDown', button: e.button + 1, x, y });
};
const onMouseUp = (e: MouseEvent) => {
e.preventDefault();
const { x, y } = translateCoords(e);
sendJson({ type: 'mouseUp', button: e.button + 1, x, y });
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const { x, y } = translateCoords(e);
// Normalize delta: positive = scroll up, negative = scroll down
const delta = e.deltaY < 0 ? 3 : -3;
sendJson({ type: 'mouseScroll', delta, x, y });
};
const onContextMenu = (e: Event) => e.preventDefault();
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('contextmenu', onContextMenu);
// --- Keyboard event handlers ---
const onKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const keySym = getKeySym(e);
if (keySym !== null) {
sendJson({ type: 'keyDown', keySym });
}
};
const onKeyUp = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const keySym = getKeySym(e);
if (keySym !== null) {
sendJson({ type: 'keyUp', keySym });
}
};
canvas.addEventListener('keydown', onKeyDown);
canvas.addEventListener('keyup', onKeyUp);
// --- Clipboard: paste handler ---
const onPaste = (e: ClipboardEvent) => {
const text = e.clipboardData?.getData('text');
if (text) {
sendJson({ type: 'clipboardWrite', text });
}
};
canvas.addEventListener('paste', onPaste);
// --- Resize observer to keep scale in sync ---
const resizeObserver = new ResizeObserver(() => {
updateScale();
});
resizeObserver.observe(container);
return () => {
canvas.removeEventListener('mousemove', onMouseMove);
canvas.removeEventListener('mousedown', onMouseDown);
canvas.removeEventListener('mouseup', onMouseUp);
canvas.removeEventListener('wheel', onWheel);
canvas.removeEventListener('contextmenu', onContextMenu);
canvas.removeEventListener('keydown', onKeyDown);
canvas.removeEventListener('keyup', onKeyUp);
canvas.removeEventListener('paste', onPaste);
resizeObserver.disconnect();
ws.close();
wsRef.current = null;
};
}, [session.connection.id, token, sendJson]);
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
@@ -110,8 +314,17 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
justifyContent: 'center',
}}
>
{/* Guacamole canvas is appended here by the effect */}
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
<canvas
ref={canvasRef}
tabIndex={0}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
outline: 'none',
cursor: 'default',
}}
/>
{/* Status overlays */}
{status === 'connecting' && (
@@ -130,14 +343,7 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
)}
{status === 'error' && (
<div
style={{
position: 'absolute',
top: 16,
left: 16,
right: 16,
}}
>
<div style={{ position: 'absolute', top: 16, left: 16, right: 16 }}>
<Alert
type="error"
showIcon
@@ -148,14 +354,7 @@ export const RdpTab: React.FC<Props> = ({ session }) => {
)}
{status === 'disconnected' && (
<div
style={{
position: 'absolute',
top: 16,
left: 16,
right: 16,
}}
>
<div style={{ position: 'absolute', top: 16, left: 16, right: 16 }}>
<Alert type="warning" showIcon message="RDP session disconnected." />
</div>
)}

View File

@@ -17,3 +17,20 @@ body { margin: 0; padding: 0; }
.ant-tabs-tabpane {
height: 100%;
}
/* Connection tree: keep icon + title on one line */
.ant-tree .ant-tree-treenode {
display: flex;
align-items: center;
white-space: nowrap;
}
.ant-tree .ant-tree-node-content-wrapper {
display: inline-flex !important;
align-items: center;
min-width: 0;
overflow: hidden;
}
.ant-tree .ant-tree-iconEle {
flex-shrink: 0;
margin-right: 4px;
}

View File

@@ -14,6 +14,10 @@ interface AppState {
setFolders: (folders: Folder[]) => void;
setConnections: (connections: Connection[]) => void;
// Selected connection (properties panel)
selectedConnectionId: string | null;
setSelectedConnection: (id: string | null) => void;
// Open sessions (tabs)
sessions: Session[];
activeSessionId: string | null;
@@ -52,6 +56,10 @@ export const useStore = create<AppState>((set, get) => ({
setFolders: (folders) => set({ folders }),
setConnections: (connections) => set({ connections }),
// Selected connection
selectedConnectionId: null,
setSelectedConnection: (id) => set({ selectedConnectionId: id }),
// Sessions
sessions: [],
activeSessionId: null,