This commit is contained in:
Timothy Jaeryang Baek 2026-11-02 00:31:46 +09:00
parent 69395d4f50
commit b51e68fbb3
9 changed files with 719 additions and 49 deletions

View file

@ -22,7 +22,6 @@ from fastapi import (
applications,
status,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
@ -221,7 +220,7 @@ from open_webui.utils import logger
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control.folders import has_folder_write_access
from open_webui.utils.actions import chat_action as chat_action_handler
from open_webui.utils.asgi_middleware import AppHTTPMiddleware
from open_webui.utils.asgi_middleware import AppHTTPMiddleware, BrowserCORSMiddleware
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
from open_webui.utils.auth import (
create_admin_user,
@ -874,7 +873,7 @@ app.add_middleware(AppHTTPMiddleware)
app.add_middleware(
CORSMiddleware,
BrowserCORSMiddleware,
allow_origins=CORS_ALLOW_ORIGIN,
allow_credentials=True,
allow_methods=['*'],

View file

@ -9,12 +9,13 @@ import asyncio
import json
import logging
import re
from contextlib import suppress
from html import escape
import posixpath
from urllib.parse import unquote, urljoin, urlsplit
from urllib.parse import quote, unquote, urljoin, urlsplit
import aiohttp
from fastapi import APIRouter, Depends, Request, Response, WebSocket
from fastapi import APIRouter, Depends, HTTPException, Request, Response, WebSocket
from fastapi.responses import JSONResponse, StreamingResponse
from open_webui.config import TERMINAL_PROXY_HEADERS
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_TOOL_SERVERS
@ -23,6 +24,7 @@ from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_connection_access
from open_webui.utils.auth import get_verified_user, get_verified_user_by_token
from open_webui.utils.browser import browser_access, browser_ticket
from open_webui.utils.headers import bearer_auth_header, normalize_bearer_token
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.terminals import (
@ -43,7 +45,7 @@ log = logging.getLogger(__name__)
router = APIRouter()
STREAMING_CONTENT_TYPES = ('application/octet-stream', 'image/', 'application/pdf')
STREAMING_CONTENT_TYPES = ('application/octet-stream', 'image/', 'application/pdf', 'text/event-stream')
ADMIN_API_PATHS = ('api/v1/policies', 'api/v1/status', 'api/v1/terminals')
# Drop the upstream's server and date: uvicorn adds its own and forwarding both duplicates them.
STRIPPED_RESPONSE_HEADERS = frozenset(
@ -159,6 +161,112 @@ def _sanitize_proxy_path(path: str) -> str | None:
return cleaned
@router.api_route(
'/{server_id}/browser/view/{sid}/{ticket}/{path:path}',
methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
)
async def browser_http(request: Request, server_id: str, sid: str, ticket: str, path: str):
user, data = await browser_access(server_id, sid, ticket)
request.state.browser_auth = data['relay']
request.scope['path_params']['chat_id'] = data.get('chat', '')
return await proxy_terminal(server_id, f'browser/view/{sid}/{data["capability"]}/{path}', request, user)
async def relay_from_browser(ws, upstream):
while True:
msg = await ws.receive()
if msg['type'] == 'websocket.disconnect':
return
if msg.get('bytes') is not None:
await upstream.send_bytes(msg['bytes'])
elif msg.get('text') is not None:
await upstream.send_str(msg['text'])
async def relay_to_browser(ws, upstream):
async for msg in upstream:
if msg.type == aiohttp.WSMsgType.TEXT:
await ws.send_text(msg.data)
elif msg.type == aiohttp.WSMsgType.BINARY:
await ws.send_bytes(msg.data)
else:
return
async def watch_access(server_id, sid, ticket):
while True:
await asyncio.sleep(10)
current_user, _ = await browser_access(server_id, sid, ticket)
latest = await Config.get('terminal_server.connections', []) or []
current = next((c for c in latest if c.get('id') == server_id), None)
if (
not current
or not current.get('enabled', True)
or not ENABLE_TOOL_SERVERS
or not await has_connection_access(current_user, current)
or not terminal_context_available(current, 'chat')
):
return
@router.websocket('/{server_id}/browser/view/{sid}/{ticket}/{kind}')
async def browser_ws(ws: WebSocket, server_id: str, sid: str, ticket: str, kind: str):
try:
user, data = await browser_access(server_id, sid, ticket)
connections = await Config.get('terminal_server.connections', []) or []
connection = next((c for c in connections if c.get('id') == server_id), None)
if (
kind not in ('ws', 'stream')
or not ENABLE_TOOL_SERVERS
or not connection
or not connection.get('enabled', True)
or not await has_connection_access(user, connection)
or not terminal_context_available(connection, 'chat')
):
raise HTTPException(403, 'Browser access denied')
chat = data.get('chat', '')
context = terminal_context_id(connection, {'chat_id': chat}, 'chat')
if terminal_context_config(connection, 'chat').get('context_id') == 'chat_id' and not context:
raise HTTPException(403, 'Saved chat required')
except HTTPException:
await ws.close(code=4001)
return
await ws.accept()
base = get_terminal_server_url(connection).replace('https://', 'wss://').replace('http://', 'ws://')
url = f'{base}/browser/view/{sid}/{quote(data["capability"], safe="")}/{kind}'
headers = {'X-User-Id': user.id, 'X-Session-Id': chat}
if context:
headers[TERMINAL_CONTEXT_HEADER] = context
if data['relay'].get('authorization'):
headers['Authorization'] = data['relay']['authorization']
tasks = []
try:
async with aiohttp.ClientSession(cookies=data['relay'].get('cookies', {}), trust_env=True) as client:
async with client.ws_connect(
url,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
max_msg_size=16 * 1024 * 1024,
) as upstream:
tasks = [
asyncio.create_task(relay_from_browser(ws, upstream)),
asyncio.create_task(relay_to_browser(ws, upstream)),
asyncio.create_task(watch_access(server_id, sid, ticket)),
]
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
except aiohttp.WSServerHandshakeError as exc:
await ws.close(code=4004 if exc.status in (401, 403, 404) else 1011)
except Exception as exc:
log.debug('Browser relay disconnected: %s', type(exc).__name__)
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
with suppress(Exception):
await ws.close(code=1011)
@router.get('/')
async def list_terminal_servers(request: Request, user=Depends(get_verified_user)):
"""Return terminal servers the authenticated user has access to."""
@ -247,7 +355,12 @@ async def proxy_terminal(
cookies = getattr(request, 'cookies', {}) if connection.get('forward_cookies', False) else {}
auth_type = connection.get('auth_type', 'bearer')
if auth_type == 'bearer':
browser_auth = getattr(request.state, 'browser_auth', None)
if browser_auth is not None:
cookies = browser_auth.get('cookies', {})
if browser_auth.get('authorization'):
headers['Authorization'] = browser_auth['authorization']
elif auth_type == 'bearer':
headers.update(bearer_auth_header(connection.get('key', '')))
elif auth_type == 'session':
headers.update(bearer_auth_header(request.state.token.credentials))
@ -266,6 +379,11 @@ async def proxy_terminal(
headers.update(bearer_auth_header(oauth_token.get('access_token', '')))
# auth_type == "none": no Authorization header
if browser_auth is not None:
for name in ('accept', 'origin', 'access-control-request-method', 'access-control-request-headers', 'last-event-id', 'range'):
if value := request.headers.get(name):
headers[name] = value
content_type = request.headers.get('content-type')
if content_type:
headers['Content-Type'] = content_type
@ -336,6 +454,11 @@ async def proxy_terminal(
# Buffer text/JSON responses
response_body = await upstream_response.read()
if safe_path.startswith('browser/sessions') and upstream_response.status < 300 and request.method in ('GET', 'POST', 'PATCH'):
browser_session = json.loads(response_body)
if isinstance(browser_session, dict) and browser_session.get('id') and browser_session.get('token'):
browser_session['token'] = browser_ticket(server_id, user.id, session_id or '', browser_session, headers, cookies)
response_body = json.dumps(browser_session).encode()
if port_prefix and 'text/html' in upstream_content_type:
response_body = _port_browser_html(response_body, port_prefix, int(port_match[1]))
status_code = upstream_response.status

View file

@ -42,12 +42,22 @@ from open_webui.internal.db import ScopedSession
from open_webui.utils.auth import get_http_authorization_cred
from open_webui.utils.security_headers import set_security_headers
from starlette.datastructures import MutableHeaders
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.types import ASGIApp, Message, Receive, Scope, Send
log = logging.getLogger(__name__)
class BrowserCORSMiddleware(CORSMiddleware):
async def __call__(self, scope, receive, send):
# Sandboxed pages have an opaque origin; capability routes handle their own CORS.
if re.match(r'^/api/v1/terminals/[^/]+/browser/view/', scope.get('path', '')):
await self.app(scope, receive, send)
else:
await super().__call__(scope, receive, send)
class AppHTTPMiddleware:
"""Open WebUI's pure-ASGI HTTP middleware.

View file

@ -0,0 +1,42 @@
from fastapi import HTTPException
from open_webui.utils.auth import create_token, decode_token, get_verified_user_by_id
def browser_ticket(server_id, user_id, chat_id, session, headers=None, cookies=None):
from open_webui.utils.oauth import encrypt_data
relay = encrypt_data(
{
'authorization': (headers or {}).get('Authorization'),
'cookies': cookies or {},
}
)
return create_token(
{
'purpose': 'terminal-browser',
'server': server_id,
'user': user_id,
'chat': chat_id,
'session': session['id'],
'capability': session['token'],
'relay': relay,
},
)
async def browser_access(server_id, sid, ticket):
data = decode_token(ticket)
if (
not data
or data.get('purpose') != 'terminal-browser'
or data.get('server') != server_id
or data.get('session') != sid
):
raise HTTPException(401, 'Browser session expired')
user = await get_verified_user_by_id(data.get('user'))
if user is None:
raise HTTPException(401, 'Browser session expired')
from open_webui.utils.oauth import decrypt_data
data['relay'] = decrypt_data(data['relay'])
return user, data

View file

@ -0,0 +1,56 @@
import { terminalRequest, type TerminalConnection } from './index';
export type BrowserSession = {
id: string;
token: string;
url: string;
title: string;
mode: 'proxy' | 'chrome';
chrome_available: boolean;
};
export const createBrowserSession = (
connection: TerminalConnection,
chatId: string | null,
url = ''
) =>
terminalRequest<BrowserSession>(connection, chatId, '/browser/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
export const updateBrowserSession = (
connection: TerminalConnection,
chatId: string | null,
id: string,
fields: Partial<Pick<BrowserSession, 'url' | 'title' | 'mode'>>
) =>
terminalRequest<BrowserSession>(
connection,
chatId,
`/browser/sessions/${encodeURIComponent(id)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields)
}
);
export const closeBrowserSession = (
connection: TerminalConnection,
chatId: string | null,
id: string
) =>
terminalRequest(connection, chatId, `/browser/sessions/${encodeURIComponent(id)}`, {
method: 'DELETE',
keepalive: true
});
export const browserViewUrl = (connection: TerminalConnection, session: BrowserSession) =>
`${connection.baseUrl.replace(/\/$/, '')}/browser/view/${encodeURIComponent(session.id)}/${encodeURIComponent(session.token)}`;
export function browserFrameUrl(prefix: string, value: string) {
const url = new URL(value);
return `${prefix}/frame/${url.protocol.slice(0, -1)}/${encodeURIComponent(url.host)}${url.pathname}${url.search}${url.hash}`;
}

View file

@ -183,7 +183,16 @@ export const terminalRequest = async <T>(
...options.headers
}
});
if (!response.ok) throw new Error(`Terminal request failed (${response.status})`);
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(
typeof body?.detail === 'string'
? body.detail
: typeof body?.error === 'string'
? body.error
: `Terminal request failed (${response.status})`
);
}
return response.json();
};

View file

@ -250,8 +250,13 @@
connection: { ...selectedConnection }
});
};
const openBrowser = (port?: number, selector = $selectedTerminalId, baseUrl?: string) => {
const url = port ? `http://localhost:${port}/` : undefined;
const openBrowser = (
port?: number,
selector = $selectedTerminalId,
baseUrl?: string,
initialUrl?: string
) => {
const url = initialUrl ?? (port ? `http://localhost:${port}/` : undefined);
const existing =
url &&
tabs.tabs.find(
@ -825,6 +830,7 @@
{chatId}
active={!!$showTabs && tabs.activeId === tab.id}
initialUrl={tab.path ?? ''}
onOpenBrowser={(url) => openBrowser(undefined, tab.terminalId, tab.browserBaseUrl, url)}
overlay={dragged}
onNavigate={(path, label) => {
tabs = {

View file

@ -1,5 +1,17 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { getContext, onMount, tick } from 'svelte';
import Dropdown from '../../common/Dropdown.svelte';
import DropdownMenu from '../../common/DropdownMenu.svelte';
import Switch from '../../common/Switch.svelte';
import ChromeBrowser from './ChromeBrowser.svelte';
import {
createBrowserSession,
updateBrowserSession,
closeBrowserSession,
browserViewUrl,
browserFrameUrl,
type BrowserSession
} from '$lib/apis/terminal/browser';
import { getPortProxyUrl, type TerminalConnection } from '$lib/apis/terminal';
import PortList from '../FileNav/PortList.svelte';
import { settings } from '$lib/stores';
@ -14,7 +26,57 @@
export let initialUrl = '';
export let overlay = false;
export let onNavigate: (url: string, label: string) => void = () => {};
export let onOpenBrowser: (url: string) => void = () => {};
export let onClose: (() => void) | null = null;
let session: BrowserSession | null = null;
let sessionChatId = chatId;
let sessionConnection = connection;
let chrome: ChromeBrowser;
let switching = false;
let deviceMode: 'desktop' | 'mobile' = 'desktop';
let optionsOpen = false;
let quality: 'low' | 'balanced' | 'crisp' = 'balanced';
let chromeBack = false;
let chromeForward = false;
let frameReady = false;
$: viewUrl = session && sessionConnection ? browserViewUrl(sessionConnection, session) : '';
const persist = (url: string, title: string) => {
if (session && sessionConnection)
void updateBrowserSession(sessionConnection, sessionChatId, session.id, { url, title }).catch(
() => {}
);
};
const switchMode = async (mode: 'proxy' | 'chrome', remember = true) => {
if (!session || !sessionConnection || switching || session.mode === mode) return;
switching = true;
try {
session = await updateBrowserSession(sessionConnection, sessionChatId, session.id, {
mode,
url: currentUrl
});
frameReady = false;
if (session.mode === 'proxy') {
history = [];
historyIndex = -1;
if (currentUrl)
frameUrl = browserFrameUrl(browserViewUrl(sessionConnection, session), currentUrl);
}
error = '';
if (remember) localStorage.setItem('browserMode', mode);
} catch (reason) {
error = reason instanceof Error ? reason.message : String(reason);
} finally {
switching = false;
}
};
const frameCommand = (command: string, url?: string) =>
iframe?.contentWindow?.postMessage({ type: 'terminal-browser-command', command, url }, '*');
const reload = () =>
session?.mode === 'chrome'
? chrome?.command('reload')
: session
? frameCommand('reload')
: navigate(currentUrl, false);
let urlInput = initialUrl;
let currentUrl = '';
let frameUrl = '';
@ -23,10 +85,12 @@
let addressInput: HTMLInputElement;
let history: string[] = [];
let historyIndex = -1;
let replaceHistory = false;
let error = '';
let connectionStatus = '';
let loading = false;
$: canGoBack = historyIndex > 0;
$: canGoForward = historyIndex < history.length - 1;
$: canGoBack = session?.mode === 'chrome' ? chromeBack : historyIndex > 0;
$: canGoForward = session?.mode === 'chrome' ? chromeForward : historyIndex < history.length - 1;
const address = (value: string): URL => {
const input = value.trim();
@ -45,12 +109,18 @@
? Number(url.port || (url.protocol === 'https:' ? 443 : 80))
: null;
const source = (url: URL) => {
if (session && sessionConnection)
return browserFrameUrl(browserViewUrl(sessionConnection, session), url.href);
const port = localPort(url);
if (port === null) return url.href;
if (!baseUrl) throw new Error($i18n.t('Select a terminal to open localhost URLs.'));
return getPortProxyUrl(baseUrl, port, url.pathname.slice(1) + url.search + url.hash);
};
const remember = (url: string) => {
const remember = (url: string, replace = false) => {
if (replace && historyIndex >= 0) {
history[historyIndex] = url;
return;
}
if (history[historyIndex] === url) return;
history = [...history.slice(0, historyIndex + 1), url];
historyIndex = history.length - 1;
@ -62,9 +132,15 @@
const src = source(url);
currentUrl = url.href;
urlInput = url.href;
if (addHistory) remember(url.href);
frameUrl = src;
frameKey++;
if (!session && addHistory) remember(url.href);
if (session?.mode === 'proxy') replaceHistory = !addHistory;
if (session?.mode === 'chrome') chrome?.command('navigate', { url: url.href });
else if (session && frameReady) frameCommand(addHistory ? 'navigate' : 'replace', url.href);
else {
frameUrl = src;
frameKey++;
}
persist(url.href, url.host);
loading = true;
error = '';
onNavigate(url.href, url.host);
@ -73,13 +149,24 @@
}
};
const move = (step: number) => {
if (session?.mode === 'chrome') {
chrome?.command(step < 0 ? 'back' : 'forward');
return;
}
const next = historyIndex + step;
if (next < 0 || next >= history.length) return;
if (session) {
historyIndex = next;
frameCommand(step < 0 ? 'back' : 'forward');
return;
}
historyIndex = next;
navigate(history[next], false);
};
const loaded = () => {
loading = false;
frameReady = true;
if (session) return;
try {
const location = iframe.contentWindow?.location;
if (!location || location.href === 'about:blank') return;
@ -104,21 +191,26 @@
}
};
const receiveNavigation = (event: MessageEvent) => {
if (event.source !== iframe?.contentWindow || !baseUrl || !currentUrl) return;
if (event.source !== iframe?.contentWindow || !currentUrl) return;
const expectedOrigin = new URL(frameUrl, window.location.origin).origin;
if (event.origin !== 'null' && event.origin !== expectedOrigin) return;
const data = event.data;
if (!data || typeof data.url !== 'string') return;
try {
const url = address(data.url);
if (data.type === 'terminal-browser-popup') {
onOpenBrowser(url.href);
return;
}
if (data.type === 'terminal-browser-navigate') {
if (data.replace && historyIndex >= 0) history[historyIndex] = url.href;
navigate(url.href, !data.replace);
} else if (data.type === 'terminal-browser-state') {
if (url.origin !== new URL(currentUrl).origin) return;
if (!session && url.origin !== new URL(currentUrl).origin) return;
if (currentUrl !== url.href || document.activeElement !== addressInput) urlInput = url.href;
currentUrl = url.href;
urlInput = url.href;
remember(url.href);
remember(url.href, replaceHistory || data.replace === true);
replaceHistory = false;
persist(url.href, typeof data.title === 'string' ? data.title : url.host);
onNavigate(
url.href,
typeof data.title === 'string' && data.title.trim() ? data.title : url.host
@ -129,52 +221,170 @@
}
};
onMount(() => {
let disposed = false;
const release = () => {
if (session && sessionConnection)
void closeBrowserSession(sessionConnection, sessionChatId, session.id).catch(() => {});
};
const heartbeat = setInterval(() => {
if (session && sessionConnection)
void updateBrowserSession(sessionConnection, sessionChatId, session.id, {}).catch(() => {});
}, 60_000);
window.addEventListener('message', receiveNavigation);
if (initialUrl) navigate(initialUrl);
else addressInput?.focus();
return () => window.removeEventListener('message', receiveNavigation);
window.addEventListener('pagehide', release);
void (async () => {
if (sessionConnection) {
try {
const created = await createBrowserSession(sessionConnection, sessionChatId, initialUrl);
if (disposed) {
await closeBrowserSession(sessionConnection, sessionChatId, created.id);
return;
}
session = created;
currentUrl = created.url || initialUrl;
const savedMode = localStorage.getItem('browserMode');
const mode =
savedMode === 'proxy' || savedMode === 'chrome'
? savedMode
: created.chrome_available
? 'chrome'
: 'proxy';
if (mode !== 'chrome' || created.chrome_available) {
await switchMode(mode, false);
}
if (disposed) {
release();
return;
}
await tick();
} catch (reason) {
error = 'Browser sessions unavailable. Update Open Terminal. ' + String(reason);
return;
}
}
if (initialUrl) navigate(initialUrl);
else addressInput?.focus();
})();
return () => {
disposed = true;
window.removeEventListener('message', receiveNavigation);
window.removeEventListener('pagehide', release);
clearInterval(heartbeat);
release();
};
});
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="flex shrink-0 items-center gap-1 px-2 pt-0 pb-1.5">
{#each [{ name: 'Back', icon: 'chevron-left', disabled: !canGoBack, action: () => move(-1) }, { name: 'Forward', icon: 'chevron-right', disabled: !canGoForward, action: () => move(1) }, { name: 'Refresh', icon: 'refresh', disabled: !currentUrl, action: () => navigate(currentUrl, false) }] as button}
<div class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<div class="flex min-w-0 shrink-0 items-center gap-1 px-1.5 py-[3px] sm:px-2">
{#each [{ name: 'Back', icon: 'chevron-left', disabled: !canGoBack, action: () => move(-1) }, { name: 'Forward', icon: 'chevron-right', disabled: !canGoForward, action: () => move(1) }, { name: 'Refresh', icon: 'refresh', disabled: !currentUrl, action: reload }] as button}
<Tooltip content={$i18n.t(button.name)}
><button
type="button"
class="flex size-5 shrink-0 items-center justify-center rounded text-gray-500 hover:text-gray-800 disabled:opacity-30 dark:text-gray-400 dark:hover:text-gray-200"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:pointer-events-none disabled:opacity-35 dark:hover:bg-white/6 dark:hover:text-gray-300"
disabled={button.disabled}
aria-label={$i18n.t(button.name)}
on:click={button.action}
><Icon
name={button.icon}
size={11}
strokeWidth={1.4}
size={12}
class={button.icon === 'refresh' && loading ? 'animate-spin' : ''}
/></button
></Tooltip
>
{/each}
<form class="min-w-0 flex-1" on:submit|preventDefault={() => navigate()}>
<form class="relative min-w-0 flex-1" on:submit|preventDefault={() => navigate()}>
<input
bind:this={addressInput}
bind:value={urlInput}
aria-label={$i18n.t('URL')}
placeholder={$i18n.t('Enter URL')}
placeholder="https://example.com"
spellcheck="false"
class="h-6 w-full rounded-lg bg-gray-50 px-2 text-xs text-gray-700 outline-none dark:bg-gray-850 dark:text-gray-200"
class="h-7 w-full min-w-0 rounded-full bg-transparent py-1 pr-8 pl-3 text-left text-xs font-normal text-gray-700 outline-none transition-colors hover:bg-gray-100/60 focus:bg-gray-100 sm:px-8 sm:text-center dark:text-gray-300 dark:hover:bg-white/5 dark:focus:bg-white/8"
/>
<Tooltip content={$i18n.t('Open in new tab')}
><button
type="button"
disabled={!currentUrl && !frameUrl}
aria-label={$i18n.t('Open in new tab')}
class="absolute top-1/2 right-1 z-10 grid h-5 w-5 -translate-y-1/2 place-items-center rounded-full text-xs text-gray-500 hover:bg-gray-200/60 disabled:pointer-events-none disabled:opacity-35 dark:hover:bg-white/8"
on:click={() =>
window.open(
session
? localPort(new URL(currentUrl)) !== null
? browserFrameUrl(viewUrl, currentUrl)
: currentUrl
: frameUrl,
'_blank',
'noopener,noreferrer'
)}>↗</button
></Tooltip
>
</form>
<Tooltip content={$i18n.t('Open in new tab')}
><button
type="button"
disabled={!frameUrl}
aria-label={$i18n.t('Open in new tab')}
class="flex size-5 shrink-0 items-center justify-center rounded text-gray-500 hover:text-gray-800 disabled:opacity-30 dark:text-gray-400 dark:hover:text-gray-200"
on:click={() => window.open(frameUrl, '_blank', 'noopener,noreferrer')}
><Icon name="external-link" size={11} strokeWidth={1.4} /></button
></Tooltip
>
{#if session}
<Dropdown align="end" bind:show={optionsOpen}>
<button
type="button"
aria-label={$i18n.t('Browser options')}
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:pointer-events-none disabled:opacity-35 dark:hover:bg-white/6 dark:hover:text-gray-300"
><Icon name="three-dots" size={12} /></button
>
<div slot="content">
<DropdownMenu className="w-48 font-normal">
{#each session.chrome_available ? ['proxy', 'chrome'] : ['proxy'] as mode}
<button
type="button"
role="menuitemradio"
aria-checked={session.mode === mode}
disabled={switching}
on:click={() => {
optionsOpen = false;
void switchMode(mode as 'proxy' | 'chrome');
}}
class="font-normal disabled:opacity-40"
>
<span class="flex-1 text-left"
>{mode === 'chrome' ? 'Chrome' : $i18n.t('Proxy')}</span
>
{#if session.mode === mode}<Icon name="check" size={12} />{/if}
</button>
{/each}
{#if session.mode === 'chrome'}
<hr class="border-gray-100 dark:border-gray-800" />
{#each [{ value: 'low', label: 'Low' }, { value: 'balanced', label: 'Balanced' }, { value: 'crisp', label: 'Crisp' }] as item}
<button
type="button"
role="menuitemradio"
aria-checked={quality === item.value}
on:click={() => {
quality = item.value as typeof quality;
optionsOpen = false;
}}
class="font-normal"
>
<span class="flex-1 text-left">{$i18n.t(item.label)}</span>
{#if quality === item.value}<Icon name="check" size={12} />{/if}
</button>
{/each}
<hr class="border-gray-100 dark:border-gray-800" />
<button
type="button"
role="menuitemcheckbox"
aria-checked={deviceMode === 'mobile'}
class="font-normal"
on:click={() => (deviceMode = deviceMode === 'mobile' ? 'desktop' : 'mobile')}
>
<span class="flex-1 whitespace-nowrap text-left">{$i18n.t('Mobile view')}</span>
<div class="shrink-0" inert>
<Switch state={deviceMode === 'mobile'} />
</div>
</button>
{/if}
</DropdownMenu>
</div>
</Dropdown>
{/if}
{#if onClose}<button
type="button"
aria-label={$i18n.t('Close')}
@ -182,21 +392,50 @@
on:click={onClose}><Icon name="xmark" size={11} /></button
>{/if}
</div>
{#if error}<p role="alert" class="px-2 py-1 text-xs text-red-500">{error}</p>{/if}
{#if error}
<p role="alert" class="px-2 py-1 text-xs text-red-500">{error}</p>
{:else if connectionStatus}
<p role="status" class="px-2 py-1 text-xs text-gray-500 dark:text-gray-400">
{$i18n.t(connectionStatus)}
</p>
{/if}
<div class="relative min-h-0 flex-1">
{#if overlay}<div class="absolute inset-0 z-10"></div>{/if}
{#if frameUrl}
{#if session?.mode === 'chrome'}
<div class="h-full" class:hidden={!currentUrl}>
<ChromeBrowser
bind:this={chrome}
streamUrl={`${viewUrl}/stream`}
{active}
mobile={deviceMode === 'mobile'}
{quality}
onError={(message) => (error = message)}
onStatus={(message) => (connectionStatus = message)}
onState={(state) => {
if (!state.url) return;
if (currentUrl !== state.url || document.activeElement !== addressInput)
urlInput = state.url;
currentUrl = state.url;
chromeBack = state.canGoBack;
chromeForward = state.canGoForward;
loading = false;
onNavigate(state.url, state.title.trim() || new URL(state.url).host);
}}
/>
</div>
{:else if frameUrl}
{#key frameKey}<iframe
bind:this={iframe}
src={frameUrl}
title={$i18n.t('Browser')}
class="h-full w-full border-0 bg-white"
sandbox="allow-scripts{($settings?.terminalPreviewAllowSameOrigin ?? false)
class="block h-full w-full border-0 bg-white dark:bg-gray-900"
sandbox="allow-scripts{!session && ($settings?.terminalPreviewAllowSameOrigin ?? false)
? ' allow-same-origin'
: ''} allow-forms allow-popups allow-modals allow-downloads"
on:load={loaded}
></iframe>{/key}
{:else}<div class="flex h-full overflow-y-auto px-6 pt-8 pb-20">
{/if}
{#if !currentUrl && !frameUrl}<div class="flex h-full overflow-y-auto px-6 pt-8 pb-20">
<div class="m-auto w-full max-w-xs">
<div class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
<Icon name="browser" size={18} strokeWidth={1.4} class="text-gray-400" />

View file

@ -0,0 +1,186 @@
<script lang="ts">
import { onMount } from 'svelte';
export let streamUrl: string;
export let active = true;
export let mobile = false;
export let quality: 'low' | 'balanced' | 'crisp' = 'balanced';
export let onState: (state: {
url: string;
title: string;
canGoBack: boolean;
canGoForward: boolean;
}) => void;
export let onError: (error: string) => void = () => {};
export let onStatus: (status: string) => void = () => {};
let surface: HTMLDivElement;
let frame = '';
let width = 1024;
let height = 768;
let socket: WebSocket | null = null;
let disposed = false;
let retry: ReturnType<typeof setTimeout>;
let attempts = 0;
let hasConnected = false;
let pendingNavigation: string | null = null;
export const command = (type: string, values: Record<string, unknown> = {}) => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type, ...values }));
} else if (type === 'navigate') {
pendingNavigation = values.url as string;
}
};
const resize = () => {
if (!surface) return;
const bounds = surface.getBoundingClientRect();
if (!bounds.width || !bounds.height) return;
width = mobile ? 390 : Math.max(240, Math.round(bounds.width));
height = mobile ? 844 : Math.max(200, Math.round(bounds.height));
command('viewport', { width, height, mobile });
};
$: if (socket) command('visibility', { visible: active });
$: if (surface && active) {
mobile;
resize();
}
$: if (socket) command('quality', { quality });
const modifiers = (e: MouseEvent | KeyboardEvent) =>
(e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
const mouse = (event: MouseEvent, type: string) => {
const image = surface.querySelector('img');
if (!image) return;
const box = image.getBoundingClientRect();
event.preventDefault();
if (type === 'mousePressed') surface.focus();
command('mouse', {
event: type,
x: Math.max(0, Math.min(width, ((event.clientX - box.left) * width) / box.width)),
y: Math.max(0, Math.min(height, ((event.clientY - box.top) * height) / box.height)),
button: ['left', 'middle', 'right'][event.button] ?? 'none',
buttons: event.buttons,
clickCount: type === 'mouseMoved' ? 0 : event.detail || 1,
modifiers: modifiers(event)
});
};
const key = (event: KeyboardEvent, type: string) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') return;
event.preventDefault();
const editing =
(event.ctrlKey || event.metaKey) &&
(
{ a: 'selectAll', c: 'copy', x: 'cut', z: event.shiftKey ? 'redo' : 'undo' } as Record<
string,
string
>
)[event.key.toLowerCase()];
command('key', {
event: type,
key: event.key,
code: event.code,
windowsVirtualKeyCode: event.keyCode,
modifiers: modifiers(event),
...(editing ? { commands: [editing] } : {}),
text:
type === 'keyDown' && event.key.length === 1 && !event.ctrlKey && !event.metaKey
? event.key
: undefined
});
};
onMount(() => {
onStatus('Connecting…');
const connect = () => {
const url = new URL(streamUrl, window.location.origin);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(url);
socket.onopen = () => {
onError('');
resize();
command('quality', { quality });
command('visibility', { visible: active });
if (pendingNavigation) {
command('navigate', { url: pendingNavigation });
pendingNavigation = null;
}
};
socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'frame' || message.type === 'state') {
hasConnected = true;
attempts = 0;
onStatus('');
}
if (message.type === 'frame') frame = `data:image/jpeg;base64,${message.data}`;
if (message.type === 'state') {
onState(message);
onError(message.error || '');
}
} catch {
/* Ignore malformed stream messages. */
}
};
socket.onclose = (event) => {
if (disposed) return;
if (event.code >= 4000 && event.code < 5000) {
onStatus('');
onError(
event.code === 4000
? 'Browser opened in another viewer.'
: 'Browser session expired. Reopen this tab.'
);
return;
}
onError('');
onStatus(hasConnected ? 'Reconnecting…' : 'Connecting…');
retry = setTimeout(connect, Math.min(1000 * 2 ** attempts++, 10000));
};
};
connect();
const observer = new ResizeObserver(resize);
observer.observe(surface);
return () => {
disposed = true;
onStatus('');
clearTimeout(retry);
observer.disconnect();
socket?.close();
};
});
</script>
<div
bind:this={surface}
class="flex h-full w-full items-start justify-center overflow-hidden outline-none"
tabindex="0"
role="application"
aria-label="Browser page"
on:keydown={(event) => key(event, 'keyDown')}
on:keyup={(event) => key(event, 'keyUp')}
on:paste={(event) => {
event.preventDefault();
command('paste', { text: event.clipboardData?.getData('text/plain') ?? '' });
}}
on:mousedown={(event) => mouse(event, 'mousePressed')}
on:mouseup={(event) => mouse(event, 'mouseReleased')}
on:mousemove={(event) => mouse(event, 'mouseMoved')}
on:contextmenu|preventDefault
on:wheel|preventDefault={(event) => {
const box = surface.querySelector('img')?.getBoundingClientRect();
if (!box) return;
command('mouse', {
event: 'mouseWheel',
x: ((event.clientX - box.left) * width) / box.width,
y: ((event.clientY - box.top) * height) / box.height,
deltaX: event.deltaX,
deltaY: event.deltaY,
modifiers: modifiers(event)
});
}}
>
{#if frame}<img
src={frame}
alt="Browser page"
draggable="false"
class="max-h-full max-w-full select-none"
style:aspect-ratio={`${width}/${height}`}
/>{/if}
</div>