From 9d86ac8186f4a84f724ccdeb889827c84f576111 Mon Sep 17 00:00:00 2001 From: volker Date: Tue, 31 Mar 2026 20:44:28 +0200 Subject: [PATCH] Add remote desktop (noVNC) viewer to terminal server feature - Backend: WebSocket proxy for noVNC/websockify with JWT auth via query param - Frontend: DesktopViewer component with start/stop/refresh/fullscreen controls - API helpers for desktop status, start, stop - Unit tests for backend router and frontend API helpers Ref: https://github.com/open-webui/open-terminal/pull/100 --- backend/open_webui/routers/terminals.py | 146 ++++++ .../test/apps/webui/routers/test_terminals.py | 433 ++++++++++++++++++ src/lib/apis/terminal/desktop.test.ts | 135 ++++++ src/lib/apis/terminal/index.ts | 63 +++ src/lib/components/chat/FileNav.svelte | 157 ++++++- .../chat/FileNav/DesktopViewer.svelte | 331 +++++++++++++ 6 files changed, 1253 insertions(+), 12 deletions(-) create mode 100644 backend/open_webui/test/apps/webui/routers/test_terminals.py create mode 100644 src/lib/apis/terminal/desktop.test.ts create mode 100644 src/lib/components/chat/FileNav/DesktopViewer.svelte diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 34d5eb96d6..e991ac021a 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -332,3 +332,149 @@ async def ws_terminal( await ws.close() except Exception: pass + + +# --------------------------------------------------------------------------- +# WebSocket proxy for remote desktop (noVNC / websockify) +# --------------------------------------------------------------------------- + +_DESKTOP_WS_TIMEOUT = 10.0 + + +async def _resolve_desktop_connection(ws: WebSocket, server_id: str): + """Authenticate a desktop WebSocket and resolve the terminal server. + + noVNC opens a raw binary WebSocket (RFB protocol) so it cannot send + a JSON auth message first. Instead the JWT is passed as a ``token`` + query parameter on the WebSocket URL. + + Returns ``(user, connection)`` on success, or ``None`` after closing *ws*. + """ + from open_webui.utils.auth import decode_token + + token = ws.query_params.get('token', '') + data = decode_token(token) + if data is None or 'id' not in data: + await ws.close(code=4001, reason='Invalid or missing token') + return None + user = Users.get_user_by_id(data['id']) + if user is None: + await ws.close(code=4001, reason='User not found') + return None + + connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connection = next((c for c in connections if c.get('id') == server_id), None) + if connection is None: + await ws.close(code=4004, reason='Terminal server not found') + return None + + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + if not has_connection_access(user, connection, user_group_ids): + await ws.close(code=4003, reason='Access denied') + return None + + return user, connection + + +async def _fetch_novnc_port(base_url: str, api_key: str) -> int: + """Query the terminal server's ``GET /desktop`` endpoint and return the + noVNC port. Falls back to 6080 if the request fails. + """ + try: + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=5, connect=3), + trust_env=True, + ) as session: + headers = {} + if api_key: + headers['Authorization'] = f'Bearer {api_key}' + async with session.get( + f'{base_url}/desktop', + headers=headers, + ) as resp: + if resp.status == 200: + data = await resp.json() + return data.get('novnc_port', 6080) + except Exception: + log.warning('Could not fetch desktop status from %s, defaulting to port 6080', base_url) + return 6080 + + +@router.websocket('/{server_id}/desktop/ws') +async def ws_desktop(ws: WebSocket, server_id: str): + """Proxy a noVNC WebSocket session to the terminal server's websockify. + + The JWT is passed as a ``token`` query parameter because noVNC uses raw + binary RFB protocol and cannot send a JSON auth message. After + authentication the proxy connects to the terminal server's websockify + port (queried from ``GET /desktop``) and performs a raw binary WebSocket + relay. + """ + await ws.accept() + + result = await _resolve_desktop_connection(ws, server_id) + if result is None: + return + user, connection = result + + base_url = (connection.get('url') or '').rstrip('/') + if not base_url: + await ws.close(code=4003, reason='Terminal server URL not configured') + return + + api_key = connection.get('key', '') + novnc_port = await _fetch_novnc_port(base_url, api_key) + + from urllib.parse import urlparse + + parsed = urlparse(base_url) + host = parsed.hostname or 'localhost' + ws_scheme = 'wss' if parsed.scheme == 'https' else 'ws' + upstream_url = f'{ws_scheme}://{host}:{novnc_port}/websockify' + + session = aiohttp.ClientSession() + try: + async with session.ws_connect(upstream_url) as upstream: + import asyncio + + async def _client_to_upstream(): + try: + while True: + msg = await ws.receive() + if msg['type'] == 'websocket.disconnect': + break + elif 'bytes' in msg and msg['bytes']: + await upstream.send_bytes(msg['bytes']) + elif 'text' in msg and msg['text']: + await upstream.send_str(msg['text']) + except Exception: + pass + + async def _upstream_to_client(): + try: + async for msg in upstream: + if msg.type == aiohttp.WSMsgType.BINARY: + await ws.send_bytes(msg.data) + elif msg.type == aiohttp.WSMsgType.TEXT: + await ws.send_text(msg.data) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.ERROR, + ): + break + except Exception: + pass + + await asyncio.gather( + _client_to_upstream(), + _upstream_to_client(), + return_exceptions=True, + ) + except Exception as e: + log.exception('Desktop WebSocket proxy error: %s', e) + finally: + await session.close() + try: + await ws.close() + except Exception: + pass diff --git a/backend/open_webui/test/apps/webui/routers/test_terminals.py b/backend/open_webui/test/apps/webui/routers/test_terminals.py new file mode 100644 index 0000000000..8133511572 --- /dev/null +++ b/backend/open_webui/test/apps/webui/routers/test_terminals.py @@ -0,0 +1,433 @@ +"""Unit tests for the terminals reverse-proxy router. + +Covers: +- _sanitize_proxy_path (path traversal prevention) +- _fetch_novnc_port (noVNC port discovery with fallback) +- HTTP proxy endpoints (list, proxy, desktop status/start) +- WebSocket route registration +""" + +import json +import sys +import types +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +# --------------------------------------------------------------------------- +# Pre-populate sys.modules with lightweight stubs for the open_webui +# dependency chain so that ``open_webui.routers.terminals`` can be +# imported without pulling in peewee/sqlalchemy/etc. +# --------------------------------------------------------------------------- + + +def _setup_module_mocks(): + needed = [ + 'open_webui.utils', + 'open_webui.utils.auth', + 'open_webui.utils.access_control', + 'open_webui.models', + 'open_webui.models.groups', + 'open_webui.models.users', + 'open_webui.internal', + 'open_webui.internal.db', + ] + for mod_name in needed: + if mod_name not in sys.modules: + sys.modules[mod_name] = types.ModuleType(mod_name) + + sys.modules['open_webui.utils.auth'].get_verified_user = lambda: None + sys.modules['open_webui.utils.access_control'].has_connection_access = lambda *a, **kw: True + sys.modules['open_webui.models.groups'].Groups = MagicMock() + sys.modules['open_webui.models.users'].Users = MagicMock() + + +_setup_module_mocks() + +from open_webui.routers.terminals import ( # noqa: E402 + router, + _sanitize_proxy_path, + _fetch_novnc_port, +) + + +# --------------------------------------------------------------------------- +# _sanitize_proxy_path +# --------------------------------------------------------------------------- + + +class TestSanitizeProxyPath: + def test_simple_path(self): + assert _sanitize_proxy_path('api/config') == 'api/config' + + def test_trailing_slash_preserved(self): + assert _sanitize_proxy_path('api/config/') == 'api/config/' + + def test_encoded_slashes(self): + assert _sanitize_proxy_path('files/read%3Ffoo') == 'files/read?foo' + + def test_dotdot_rejected(self): + assert _sanitize_proxy_path('../../../etc/passwd') is None + + def test_dot_only_rejected(self): + assert _sanitize_proxy_path('.') is None + + def test_empty_rejected(self): + assert _sanitize_proxy_path('') is None + + def test_double_slash_cleaned(self): + assert _sanitize_proxy_path('foo//bar') == 'foo/bar' + + def test_leading_slash_stripped(self): + assert _sanitize_proxy_path('/api/config') == 'api/config' + + +# --------------------------------------------------------------------------- +# _fetch_novnc_port +# --------------------------------------------------------------------------- + + +class TestFetchNovncPort: + @pytest.mark.asyncio + async def test_returns_port_from_server(self): + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={'running': True, 'novnc_port': 6090}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = AsyncMock() + mock_session.get = MagicMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session): + port = await _fetch_novnc_port('http://terminal:8080', 'key123') + assert port == 6090 + + @pytest.mark.asyncio + async def test_fallback_on_failure(self): + mock_session = AsyncMock() + mock_session.get = MagicMock(side_effect=Exception('connection refused')) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + with patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session): + port = await _fetch_novnc_port('http://terminal:8080', 'key123') + assert port == 6080 + + +def _make_app(): + app = FastAPI() + app.include_router(router, prefix='/api/v1/terminals') + return app + + +@pytest.fixture() +def app(): + return _make_app() + + +@pytest.fixture() +def client(app): + return TestClient(app, raise_server_exceptions=False) + + +@pytest.fixture() +def mock_user(): + user = MagicMock() + user.id = 'test-user-id' + user.email = 'test@example.com' + user.role = 'user' + return user + + +@pytest.fixture() +def mock_connection(): + return { + 'id': 'srv1', + 'name': 'Test Server', + 'url': 'http://terminal-server:8080', + 'key': 'secret-key', + 'auth_type': 'bearer', + 'enabled': True, + 'policy_id': None, + } + + +@pytest.fixture() +def mock_config(mock_connection): + return MagicMock(TERMINAL_SERVER_CONNECTIONS=[mock_connection]) + + +def _override_auth(app, user): + from open_webui.utils.auth import get_verified_user + + app.dependency_overrides[get_verified_user] = lambda: user + + +def _clear_auth(app): + from open_webui.utils.auth import get_verified_user + + app.dependency_overrides.pop(get_verified_user, None) + + +# --------------------------------------------------------------------------- +# list terminal servers +# --------------------------------------------------------------------------- + + +class TestListTerminals: + def test_returns_configured_servers(self, client, app, mock_user, mock_config): + _override_auth(app, mock_user) + app.state.config = mock_config + + with ( + patch('open_webui.routers.terminals.has_connection_access', return_value=True), + patch('open_webui.routers.terminals.Groups') as MockGroups, + ): + MockGroups.get_groups_by_member_id.return_value = [] + resp = client.get('/api/v1/terminals/') + + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]['id'] == 'srv1' + assert data[0]['name'] == 'Test Server' + _clear_auth(app) + + def test_filters_by_access(self, client, app, mock_user, mock_config): + _override_auth(app, mock_user) + app.state.config = mock_config + + with ( + patch('open_webui.routers.terminals.has_connection_access', return_value=False), + patch('open_webui.routers.terminals.Groups') as MockGroups, + ): + MockGroups.get_groups_by_member_id.return_value = [] + resp = client.get('/api/v1/terminals/') + + assert resp.status_code == 200 + assert resp.json() == [] + _clear_auth(app) + + def test_skips_disabled_servers(self, client, app, mock_user): + _override_auth(app, mock_user) + conn = { + 'id': 'srv2', + 'name': 'Disabled', + 'url': 'http://ts:8080', + 'key': 'k', + 'enabled': False, + } + app.state.config = MagicMock(TERMINAL_SERVER_CONNECTIONS=[conn]) + + with patch('open_webui.routers.terminals.Groups') as MockGroups: + MockGroups.get_groups_by_member_id.return_value = [] + resp = client.get('/api/v1/terminals/') + + assert resp.status_code == 200 + assert resp.json() == [] + _clear_auth(app) + + +# --------------------------------------------------------------------------- +# HTTP proxy: proxy_terminal +# --------------------------------------------------------------------------- + + +class TestProxyTerminal: + def test_404_for_unknown_server(self, client, app, mock_user, mock_config): + _override_auth(app, mock_user) + app.state.config = mock_config + + resp = client.get('/api/v1/terminals/nonexistent/api/config') + assert resp.status_code == 404 + _clear_auth(app) + + def test_403_for_no_access(self, client, app, mock_user, mock_config): + _override_auth(app, mock_user) + app.state.config = mock_config + + with ( + patch('open_webui.routers.terminals.has_connection_access', return_value=False), + patch('open_webui.routers.terminals.Groups') as MockGroups, + ): + MockGroups.get_groups_by_member_id.return_value = [] + resp = client.get('/api/v1/terminals/srv1/api/config') + + assert resp.status_code == 403 + _clear_auth(app) + + def test_400_for_invalid_path(self, client, app, mock_user, mock_config): + _override_auth(app, mock_user) + app.state.config = mock_config + + with ( + patch('open_webui.routers.terminals.has_connection_access', return_value=True), + patch('open_webui.routers.terminals.Groups') as MockGroups, + ): + MockGroups.get_groups_by_member_id.return_value = [] + resp = client.get('/api/v1/terminals/srv1/..%2F..%2Fetc%2Fpasswd') + + assert resp.status_code == 400 + _clear_auth(app) + + def test_proxies_json_response(self, client, app, mock_user, mock_config): + _override_auth(app, mock_user) + app.state.config = mock_config + + upstream_body = {'features': {'terminal': True, 'desktop': True}} + + mock_upstream = AsyncMock() + mock_upstream.status = 200 + mock_upstream.headers = {'content-type': 'application/json'} + mock_upstream.read = AsyncMock(return_value=json.dumps(upstream_body).encode()) + mock_upstream.release = AsyncMock() + + mock_session_instance = AsyncMock() + mock_session_instance.request = AsyncMock(return_value=mock_upstream) + mock_session_instance.close = AsyncMock() + + with ( + patch('open_webui.routers.terminals.has_connection_access', return_value=True), + patch('open_webui.routers.terminals.Groups') as MockGroups, + patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session_instance), + ): + MockGroups.get_groups_by_member_id.return_value = [] + resp = client.get('/api/v1/terminals/srv1/api/config') + + assert resp.status_code == 200 + assert resp.json() == upstream_body + + call_kwargs = mock_session_instance.request.call_args + target_url = call_kwargs.kwargs.get('url', call_kwargs.args[1] if len(call_kwargs.args) > 1 else '') + assert target_url == 'http://terminal-server:8080/api/config' + _clear_auth(app) + + def test_proxies_with_bearer_auth_header(self, client, app, mock_user, mock_config): + _override_auth(app, mock_user) + app.state.config = mock_config + + mock_upstream = AsyncMock() + mock_upstream.status = 200 + mock_upstream.headers = {'content-type': 'application/json'} + mock_upstream.read = AsyncMock(return_value=b'{}') + mock_upstream.release = AsyncMock() + + mock_session_instance = AsyncMock() + mock_session_instance.request = AsyncMock(return_value=mock_upstream) + mock_session_instance.close = AsyncMock() + + with ( + patch('open_webui.routers.terminals.has_connection_access', return_value=True), + patch('open_webui.routers.terminals.Groups') as MockGroups, + patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session_instance), + ): + MockGroups.get_groups_by_member_id.return_value = [] + client.get('/api/v1/terminals/srv1/api/config') + + call_kwargs = mock_session_instance.request.call_args + headers = call_kwargs.kwargs.get('headers', {}) + assert headers.get('Authorization') == 'Bearer secret-key' + _clear_auth(app) + + def test_proxies_desktop_status_endpoint(self, client, app, mock_user, mock_config): + _override_auth(app, mock_user) + app.state.config = mock_config + + desktop_status = { + 'running': True, + 'display': ':0', + 'vnc_port': 5900, + 'novnc_port': 6080, + 'screen_width': 1280, + 'screen_height': 720, + } + + mock_upstream = AsyncMock() + mock_upstream.status = 200 + mock_upstream.headers = {'content-type': 'application/json'} + mock_upstream.read = AsyncMock(return_value=json.dumps(desktop_status).encode()) + mock_upstream.release = AsyncMock() + + mock_session_instance = AsyncMock() + mock_session_instance.request = AsyncMock(return_value=mock_upstream) + mock_session_instance.close = AsyncMock() + + with ( + patch('open_webui.routers.terminals.has_connection_access', return_value=True), + patch('open_webui.routers.terminals.Groups') as MockGroups, + patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session_instance), + ): + MockGroups.get_groups_by_member_id.return_value = [] + resp = client.get('/api/v1/terminals/srv1/desktop') + + assert resp.status_code == 200 + data = resp.json() + assert data['running'] is True + assert data['novnc_port'] == 6080 + _clear_auth(app) + + def test_proxies_desktop_start_endpoint(self, client, app, mock_user, mock_config): + _override_auth(app, mock_user) + app.state.config = mock_config + + start_response = {'running': True, 'novnc_port': 6080} + + mock_upstream = AsyncMock() + mock_upstream.status = 200 + mock_upstream.headers = {'content-type': 'application/json'} + mock_upstream.read = AsyncMock(return_value=json.dumps(start_response).encode()) + mock_upstream.release = AsyncMock() + + mock_session_instance = AsyncMock() + mock_session_instance.request = AsyncMock(return_value=mock_upstream) + mock_session_instance.close = AsyncMock() + + with ( + patch('open_webui.routers.terminals.has_connection_access', return_value=True), + patch('open_webui.routers.terminals.Groups') as MockGroups, + patch('open_webui.routers.terminals.aiohttp.ClientSession', return_value=mock_session_instance), + ): + MockGroups.get_groups_by_member_id.return_value = [] + resp = client.post('/api/v1/terminals/srv1/desktop/start') + + assert resp.status_code == 200 + assert resp.json()['running'] is True + call_kwargs = mock_session_instance.request.call_args + assert call_kwargs.kwargs.get('method') == 'POST' + _clear_auth(app) + + def test_503_for_empty_url(self, client, app, mock_user): + _override_auth(app, mock_user) + conn = {'id': 'srv3', 'name': 'No URL', 'url': '', 'key': 'k', 'enabled': True} + app.state.config = MagicMock(TERMINAL_SERVER_CONNECTIONS=[conn]) + + with ( + patch('open_webui.routers.terminals.has_connection_access', return_value=True), + patch('open_webui.routers.terminals.Groups') as MockGroups, + ): + MockGroups.get_groups_by_member_id.return_value = [] + resp = client.get('/api/v1/terminals/srv3/api/config') + + assert resp.status_code == 503 + _clear_auth(app) + + +# --------------------------------------------------------------------------- +# WebSocket route registration +# --------------------------------------------------------------------------- + + +class TestDesktopWsRoute: + def test_ws_desktop_endpoint_registered(self, app): + routes = [r.path for r in app.routes] + assert '/api/v1/terminals/{server_id}/desktop/ws' in routes + + def test_ws_terminal_endpoint_registered(self, app): + routes = [r.path for r in app.routes] + assert '/api/v1/terminals/{server_id}/api/terminals/{session_id}' in routes diff --git a/src/lib/apis/terminal/desktop.test.ts b/src/lib/apis/terminal/desktop.test.ts new file mode 100644 index 0000000000..f7b0b07e95 --- /dev/null +++ b/src/lib/apis/terminal/desktop.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for the desktop (noVNC) API helpers in src/lib/apis/terminal/index.ts + * + * We mock fetch globally so no network calls are made. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/constants', () => ({ + WEBUI_API_BASE_URL: 'http://localhost:8080/api' +})); + +import { + getDesktopStatus, + startDesktop, + stopDesktop, + getDesktopViewerUrl, + getPortProxyUrl +} from '$lib/apis/terminal'; + +const BASE = 'http://terminal:8080'; +const API_KEY = 'test-key'; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('getDesktopStatus', () => { + it('returns parsed DesktopStatus on success', async () => { + const body = { + running: true, + display: ':0', + vnc_port: 5900, + novnc_port: 6080, + screen_width: 1280, + screen_height: 720 + }; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(body) + }); + + const result = await getDesktopStatus(BASE, API_KEY); + expect(result).toEqual(body); + expect(fetch).toHaveBeenCalledWith( + 'http://terminal:8080/desktop', + expect.objectContaining({ + headers: { Authorization: 'Bearer test-key' } + }) + ); + }); + + it('returns null on non-ok response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false }); + const result = await getDesktopStatus(BASE, API_KEY); + expect(result).toBeNull(); + }); + + it('returns null on network error', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('network')); + const result = await getDesktopStatus(BASE, API_KEY); + expect(result).toBeNull(); + }); +}); + +describe('startDesktop', () => { + it('posts to desktop/start and returns status', async () => { + const body = { running: true, novnc_port: 6080 }; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(body) + }); + + const result = await startDesktop(BASE, API_KEY); + expect(result).toEqual(body); + expect(fetch).toHaveBeenCalledWith( + 'http://terminal:8080/desktop/start', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('returns null on failure', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ detail: 'error' }) + }); + const result = await startDesktop(BASE, API_KEY); + expect(result).toBeNull(); + }); +}); + +describe('stopDesktop', () => { + it('returns true on success', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }); + const result = await stopDesktop(BASE, API_KEY); + expect(result).toBe(true); + expect(fetch).toHaveBeenCalledWith( + 'http://terminal:8080/desktop/stop', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('returns false on failure', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('fail')); + const result = await stopDesktop(BASE, API_KEY); + expect(result).toBe(false); + }); +}); + +describe('getDesktopViewerUrl', () => { + it('builds viewer URL with default port', () => { + expect(getDesktopViewerUrl('http://host:8080')).toBe('http://host:8080/proxy/6080/vnc.html'); + }); + + it('builds viewer URL with custom port', () => { + expect(getDesktopViewerUrl('http://host:8080', 6090)).toBe( + 'http://host:8080/proxy/6090/vnc.html' + ); + }); + + it('strips trailing slash', () => { + expect(getDesktopViewerUrl('http://host:8080/')).toBe('http://host:8080/proxy/6080/vnc.html'); + }); +}); + +describe('getPortProxyUrl', () => { + it('builds proxy URL with path', () => { + expect(getPortProxyUrl('http://host:8080', 3000, 'api/data')).toBe( + 'http://host:8080/proxy/3000/api/data' + ); + }); + + it('builds proxy URL without path', () => { + expect(getPortProxyUrl('http://host:8080', 3000)).toBe('http://host:8080/proxy/3000/'); + }); +}); diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index de2e2fd5a6..07e73d8f87 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -13,6 +13,7 @@ export type ListeningPort = { export type TerminalFeatures = { terminal?: boolean; + desktop?: boolean; }; import { WEBUI_API_BASE_URL } from '$lib/constants'; @@ -304,6 +305,68 @@ export const getPortProxyUrl = (baseUrl: string, port: number, path: string = '' return `${baseUrl.replace(/\/$/, '')}/proxy/${port}/${path}`; }; +// --------------------------------------------------------------------------- +// Remote desktop (noVNC) +// --------------------------------------------------------------------------- + +export type DesktopStatus = { + running: boolean; + display?: string; + vnc_port?: number; + novnc_port?: number; + screen_width?: number; + screen_height?: number; +}; + +export const getDesktopStatus = async ( + baseUrl: string, + apiKey: string +): Promise => { + const url = `${baseUrl.replace(/\/$/, '')}/desktop`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` } + }).catch(() => null); + if (!res || !res.ok) return null; + return res.json().catch(() => null); +}; + +export const startDesktop = async ( + baseUrl: string, + apiKey: string +): Promise => { + const url = `${baseUrl.replace(/\/$/, '')}/desktop/start`; + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` } + }) + .then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.detail ?? `HTTP ${res.status}`); + } + return res.json(); + }) + .catch((err) => { + console.error('open-terminal startDesktop error:', err); + return null; + }); + return res; +}; + +export const stopDesktop = async (baseUrl: string, apiKey: string): Promise => { + const url = `${baseUrl.replace(/\/$/, '')}/desktop/stop`; + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` } + }).catch(() => null); + return res?.ok ?? false; +}; + +export const getDesktopViewerUrl = (baseUrl: string, novncPort: number = 6080): string => { + const base = baseUrl.replace(/\/$/, ''); + return `${base}/proxy/${novncPort}/vnc.html`; +}; + // --------------------------------------------------------------------------- // Notebook execution // --------------------------------------------------------------------------- diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 2a14312d45..7c6f01aede 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -44,6 +44,7 @@ import PortList from './FileNav/PortList.svelte'; import PortPreview from './FileNav/PortPreview.svelte'; import XTerminal from './XTerminal.svelte'; + import DesktopViewer from './FileNav/DesktopViewer.svelte'; const i18n = getContext('i18n'); @@ -55,11 +56,16 @@ let terminalExpanded = false; let terminalHeight = 200; // px, default when expanded let isDraggingHandle = false; + let isDraggingDesktopHandle = false; let containerEl: HTMLElement; let terminalConnected = false; let terminalConnecting = false; let terminalEnabled = true; + let desktopEnabled = false; + let desktopExpanded = false; + let desktopHeight = 300; + const toggleTerminal = () => { terminalExpanded = !terminalExpanded; }; @@ -86,6 +92,28 @@ window.addEventListener('mouseup', onMouseUp); }; + const onDesktopHandleMouseDown = (e: MouseEvent) => { + e.preventDefault(); + isDraggingDesktopHandle = true; + const startY = e.clientY; + const startHeight = desktopHeight; + + const onMouseMove = (ev: MouseEvent) => { + const delta = startY - ev.clientY; + const maxH = containerEl ? containerEl.clientHeight - 100 : 500; + desktopHeight = Math.max(80, Math.min(maxH, startHeight + delta)); + }; + + const onMouseUp = () => { + isDraggingDesktopHandle = false; + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + }; + // ── Directory state ────────────────────────────────────────────────── let currentPath = savedPath; let entries: FileEntry[] = []; @@ -249,6 +277,7 @@ if (terminalChanged) { const config = await getTerminalConfig(terminal.url, terminal.key); terminalEnabled = config?.features?.terminal !== false; + desktopEnabled = config?.features?.desktop === true; } const rawCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined); @@ -365,22 +394,52 @@ clearFilePreview(); if (isImage(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); + const result = await downloadFileBlob( + terminal.url, + terminal.key, + filePath, + chatId ?? undefined + ); if (result) fileImageUrl = URL.createObjectURL(result.blob); } else if (isVideo(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); + const result = await downloadFileBlob( + terminal.url, + terminal.key, + filePath, + chatId ?? undefined + ); if (result) fileVideoUrl = URL.createObjectURL(result.blob); } else if (isAudio(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); + const result = await downloadFileBlob( + terminal.url, + terminal.key, + filePath, + chatId ?? undefined + ); if (result) fileAudioUrl = URL.createObjectURL(result.blob); } else if (isPdf(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); + const result = await downloadFileBlob( + terminal.url, + terminal.key, + filePath, + chatId ?? undefined + ); if (result) filePdfData = await result.blob.arrayBuffer(); } else if (isSqlite(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); + const result = await downloadFileBlob( + terminal.url, + terminal.key, + filePath, + chatId ?? undefined + ); if (result) fileSqliteData = await result.blob.arrayBuffer(); } else if (isOffice(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); + const result = await downloadFileBlob( + terminal.url, + terminal.key, + filePath, + chatId ?? undefined + ); if (result) { const ext = getFileExt(filePath); const arrayBuffer = await result.blob.arrayBuffer(); @@ -493,7 +552,12 @@ const terminal = selectedTerminal; if (!terminal) return; - const result = await createDirectory(terminal.url, terminal.key, `${currentPath}${name}`, chatId ?? undefined); + const result = await createDirectory( + terminal.url, + terminal.key, + `${currentPath}${name}`, + chatId ?? undefined + ); toast[result ? 'success' : 'error']( $i18n.t(result ? 'Folder created' : 'Failed to create folder') ); @@ -554,7 +618,13 @@ const sourceDir = source.endsWith('/') ? source : source + '/'; if (destFolder.startsWith(sourceDir)) return; - const result = await moveEntry(terminal.url, terminal.key, source, destination, chatId ?? undefined); + const result = await moveEntry( + terminal.url, + terminal.key, + source, + destination, + chatId ?? undefined + ); if ('error' in result) { toast.error(result.error); } else { @@ -573,7 +643,13 @@ if (oldPath === destination) return; - const result = await moveEntry(terminal.url, terminal.key, oldPath, destination, chatId ?? undefined); + const result = await moveEntry( + terminal.url, + terminal.key, + oldPath, + destination, + chatId ?? undefined + ); if ('error' in result) { toast.error(result.error); } else { @@ -1316,6 +1392,63 @@ {/if} + + {#if desktopEnabled} +
+ {#if desktopExpanded} + +
+
+
+
+ {/if} + + + + {#if desktopExpanded} +
+ +
+ {/if} +
+ {/if} + {#if selectedTerminal && !selectedFile && previewPort === null}
@@ -1340,8 +1473,8 @@
-
+ >
+
{/if} @@ -1371,7 +1504,7 @@ : terminalConnecting ? 'bg-yellow-500 animate-pulse' : 'bg-gray-400'}" - /> + >
{/if} + import { onMount, onDestroy, getContext } from 'svelte'; + import { + getDesktopStatus, + startDesktop, + stopDesktop, + getDesktopViewerUrl, + type DesktopStatus + } from '$lib/apis/terminal'; + import { WEBUI_API_BASE_URL } from '$lib/constants'; + import Tooltip from '$lib/components/common/Tooltip.svelte'; + + const i18n = getContext('i18n'); + + export let baseUrl: string; + export let apiKey: string; + export let overlay = false; + + let status: DesktopStatus | null = null; + let loading = false; + let starting = false; + let iframeKey = 0; + let iframeEl: HTMLIFrameElement | null = null; + let pollTimer: ReturnType | null = null; + + $: isSystemTerminal = baseUrl.includes('/terminals/'); + $: serverId = extractServerId(baseUrl); + $: novncPort = status?.novnc_port ?? 6080; + $: viewerUrl = buildViewerUrl(); + $: connected = status?.running === true; + + function extractServerId(url: string): string | null { + const match = url.match(/\/terminals\/([^/]+)/); + return match ? match[1] : null; + } + + function buildViewerUrl(): string { + if (isSystemTerminal && serverId) { + const base = `${WEBUI_API_BASE_URL}/terminals/${serverId}`; + return `${base}/proxy/${novncPort}/vnc.html`; + } + return getDesktopViewerUrl(baseUrl, novncPort); + } + + function buildWsPath(): string | null { + if (!isSystemTerminal) return null; + if (!serverId) return null; + const token = localStorage.getItem('token') ?? ''; + return `/api/v1/terminals/${serverId}/desktop/ws?token=${encodeURIComponent(token)}`; + } + + function getIframeSrc(): string { + const url = viewerUrl; + const params = new URLSearchParams({ + autoconnect: 'true', + resize: 'scale' + }); + const wsPath = buildWsPath(); + if (wsPath) { + params.set('path', wsPath); + } + return `${url}?${params.toString()}`; + } + + async function refreshStatus() { + const result = await getDesktopStatus(baseUrl, apiKey); + status = result; + } + + async function ensureRunning() { + if (status?.running) return; + starting = true; + const result = await startDesktop(baseUrl, apiKey); + if (result) { + status = result; + } else { + await refreshStatus(); + } + starting = false; + } + + async function toggleDesktop() { + if (loading) return; + loading = true; + try { + if (status?.running) { + await stopDesktop(baseUrl, apiKey); + status = { running: false }; + } else { + await ensureRunning(); + } + } finally { + loading = false; + } + } + + function refresh() { + iframeKey += 1; + } + + function openExternal() { + const url = buildExternalUrl(); + window.open(url, '_blank', 'noopener,noreferrer'); + } + + function buildExternalUrl(): string { + if (isSystemTerminal && serverId) { + const token = localStorage.getItem('token') ?? ''; + const base = `${WEBUI_API_BASE_URL}/terminals/${serverId}`; + return `${base}/proxy/${novncPort}/vnc.html?autoconnect=true&resize=scale&path=/api/v1/terminals/${serverId}/desktop/ws?token=${encodeURIComponent(token)}`; + } + return `${getDesktopViewerUrl(baseUrl, novncPort)}?autoconnect=true&resize=scale`; + } + + async function goFullscreen() { + iframeEl?.requestFullscreen?.(); + } + + function startPolling() { + stopPolling(); + pollTimer = setInterval(refreshStatus, 10000); + } + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + onMount(async () => { + await refreshStatus(); + if (!status?.running) { + await ensureRunning(); + } + startPolling(); + }); + + onDestroy(() => { + stopPolling(); + }); + + +
+ +
+ +
+
+ + {#if starting} + {$i18n.t('Starting...')} + {:else if connected} + {$i18n.t('Connected')} + {:else} + {$i18n.t('Stopped')} + {/if} + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+ + + {#if starting} +
+
+ + + + {$i18n.t('Starting virtual desktop...')} +
+
+ {:else if !connected} + +
+ +
+ {:else} + +
+ {#if overlay} +
+ {/if} + {#key iframeKey} + + {/key} +
+ {/if} +