new version full rebuild with claude opus 4.6 and own rdp daemon
This commit is contained in:
@@ -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 */}
|
||||
|
||||
188
frontend/src/components/Sidebar/ConnectionProperties.tsx
Normal file
188
frontend/src/components/Sidebar/ConnectionProperties.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user