Files
phishingclub/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte
T
Ronni Skansing a02e08fbfd Remote Browser Feature
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-05-15 14:07:49 +02:00

978 lines
37 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount, createEventDispatcher } from 'svelte';
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import { vimModeEnabled } from '$lib/store/vimMode.js';
import {
setupVimClipboardIntegration,
destroyVimClipboardIntegration
} from '$lib/utils/vimClipboard.js';
import * as vimModule from 'monaco-vim';
import TextField from '$lib/components/TextField.svelte';
import { api } from '$lib/api/apiProxy.js';
import RemoteBrowserStream from '$lib/components/remote-browser/RemoteBrowserStream.svelte';
const dispatch = createEventDispatcher();
// -------------------------------------------------------------------------
// Props
// -------------------------------------------------------------------------
/** @type {string} */
export let name = '';
/** @type {string} */
export let description = '';
/** @type {string} */
export let script = '';
/** @type {string} script is the JS source to edit */
export let config = JSON.stringify(
{ mode: 'local', remote: '', proxy: '', timeout: 300000 },
null,
2
);
/** @type {string|null} */
export let id = null;
/** @type {string} last persisted script - used to show unsaved-changes warning */
export let savedScript = '';
// -------------------------------------------------------------------------
// Editor state
// -------------------------------------------------------------------------
let editorContainer;
let editor = null;
let isDark = false;
let vimStatusBarEl = null;
let vimModeInstance = null;
let isDestroyed = false;
let localVimMode = false;
// -------------------------------------------------------------------------
// Config panel state (parsed from JSON config string)
// -------------------------------------------------------------------------
let cfgMode = 'local'; // "local" | "remote"
let cfgRemote = '';
let cfgProxy = '';
let cfgHeadless = true;
let cfgTimeout = 5; // minutes (converted to ms on save)
function parseConfig(raw) {
try {
const obj = JSON.parse(raw || '{}');
cfgMode = obj.mode || 'local';
cfgRemote = obj.remote || '';
cfgProxy = obj.proxy || '';
cfgHeadless = obj.headless ?? true;
cfgTimeout = Math.round((obj.timeout || 300000) / 60000);
} catch {
// keep defaults
}
}
function buildConfig() {
return JSON.stringify(
{
mode: cfgMode,
remote: cfgRemote,
proxy: cfgProxy,
headless: cfgHeadless,
timeout: cfgTimeout * 60000
},
null,
2
);
}
// Only rebuild config from form fields after mount (prevents overwriting the incoming prop).
let _mounted = false;
$: if (_mounted) config = buildConfig();
// When the parent passes a new config (e.g. opening a different record),
// re-parse it into the form fields - but only if it differs from what we'd build ourselves.
let _lastBuilt = '';
$: if (_mounted && config !== _lastBuilt) {
const built = buildConfig();
if (config !== built) {
parseConfig(config);
}
_lastBuilt = config;
}
// -------------------------------------------------------------------------
// Right panel tabs
// -------------------------------------------------------------------------
let activeTab = 'config'; // 'config' | 'run'
let isScriptDirty = false;
$: isScriptDirty = editor ? editor.getValue() !== savedScript : script !== savedScript;
// -------------------------------------------------------------------------
// Run / Test
// -------------------------------------------------------------------------
/** @type {WebSocket|null} */
let ws = null;
let isRunning = false;
/** @type {Array<Record<string, any>>} */
let runLog = [];
let logContainer;
// live stream (View / Control) — populated once the backend sends {"type":"session","id":"..."}
let streamSessionID = '';
let streamVisible = false;
let streamControlMode = false;
// -------------------------------------------------------------------------
// Event injection (simulate victim input)
// -------------------------------------------------------------------------
let injectEvent = '';
let injectData = '';
// -------------------------------------------------------------------------
// Screenshot modal
// -------------------------------------------------------------------------
/** @type {string|null} */
let screenshotModalSrc = null;
let screenshotModalLabel = '';
let screenshotModalURL = '';
function sendEvent() {
if (!ws || ws.readyState !== WebSocket.OPEN || !injectEvent.trim()) return;
let data;
try {
data = JSON.parse(injectData);
} catch {
data = injectData || null;
}
ws.send(JSON.stringify({ event: injectEvent.trim(), data }));
runLog = [...runLog, { type: 'sent', event: injectEvent.trim(), data, time: now() }];
setTimeout(scrollLogToBottom, 0);
injectEvent = '';
injectData = '';
}
function scrollLogToBottom() {
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight;
}
}
async function startRun() {
if (!id) {
runLog = [
...runLog,
{ type: 'error', message: 'Save the remote browser first before running.', time: now() }
];
return;
}
if (isRunning) return;
runLog = [];
isRunning = true;
activeTab = 'run';
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${window.location.host}/api/v1/remote-browser/${id}/run`;
ws = new WebSocket(url);
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'session') {
streamSessionID = msg.id;
return;
}
runLog = [...runLog, msg];
// defer scroll so DOM has updated
setTimeout(scrollLogToBottom, 0);
if (msg.type === 'done' || msg.type === 'error') {
isRunning = false;
}
} catch {
// ignore
}
};
ws.onerror = () => {
runLog = [...runLog, { type: 'error', message: 'WebSocket connection error.', time: now() }];
isRunning = false;
streamSessionID = '';
};
ws.onclose = () => {
isRunning = false;
streamSessionID = '';
};
}
function stopRun() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'stop' }));
}
isRunning = false;
}
function now() {
return new Date().toISOString();
}
// -------------------------------------------------------------------------
// Monaco editor setup
// -------------------------------------------------------------------------
const remoteBrowserDTS = `
interface SessionOptions {
/** DevTools WebSocket URL — connects to an existing Chrome instead of launching one */
remote?: string;
/** SOCKS5 or HTTP proxy, e.g. "socks5://127.0.0.1:1080" */
proxy?: string;
/** Run Chrome headless (default: from config) */
headless?: boolean;
/** Close the session after this many ms of no browser activity */
idleTimeout?: number;
/** Log every action to the test runner */
debug?: boolean;
/** Max ms for read-only CDP calls (getText, evaluate, …); 0 = no limit */
queryTimeout?: number;
/** Override the User-Agent header sent by Chrome */
userAgent?: string;
}
interface CaptureOptions {
/** Filter cookies to these domains, e.g. ["google.com"] */
domains?: string[];
/** Only keep cookies with these names */
cookieNames?: string[];
/** Include localStorage (default true unless domains is set) */
localStorage?: boolean;
/** Include sessionStorage (default true unless domains is set) */
sessionStorage?: boolean;
}
interface CaptureResult {
cookies?: Array<{ name: string; value: string; domain: string; path: string; [key: string]: any }>;
localStorage?: Record<string, string>;
sessionStorage?: Record<string, string>;
}
interface Session {
// ── Navigation ────────────────────────────────────────────────────────────
/** Navigate to a URL and wait for the page to load */
navigate(url: string): void;
navigateBack(): void;
navigateForward(): void;
reload(): void;
/** Stop the current page load */
stop(): void;
/** Returns the current page URL */
location(): string;
/** Returns the current page title */
title(): string;
// ── Waiting ───────────────────────────────────────────────────────────────
/** Wait until any of the given selectors is visible; returns the matched selector */
waitVisible(...selectors: string[]): string;
/** Wait until any of the given selectors is visible and enabled; returns the matched selector */
waitReady(...selectors: string[]): string;
/** Wait until any of the given selectors is enabled; returns the matched selector */
waitEnabled(...selectors: string[]): string;
/** Wait until any of the given selectors has a selected option; returns the matched selector */
waitSelected(...selectors: string[]): string;
/** Wait until any of the given selectors is not visible; returns the matched selector */
waitNotVisible(...selectors: string[]): string;
/** Wait until any of the given selectors is absent from the DOM; returns the matched selector */
waitNotPresent(...selectors: string[]): string;
// ── Mouse ─────────────────────────────────────────────────────────────────
click(selector: string): void;
doubleClick(selector: string): void;
clickXY(x: number, y: number): void;
scrollIntoView(selector: string): void;
// ── Keyboard ──────────────────────────────────────────────────────────────
/** Focus the element and type text character by character */
sendKeys(selector: string, text: string): void;
/** Press a named key: "Enter", "Tab", "Escape", "ArrowDown", "Backspace", … */
keyEvent(key: string): void;
// ── Form ──────────────────────────────────────────────────────────────────
/** Clear an input value and fire input/change events */
clear(selector: string): void;
focus(selector: string): void;
blur(selector: string): void;
/** Submit a form element */
submit(selector: string): void;
setValue(selector: string, value: string): void;
getValue(selector: string): string;
// ── DOM reading ───────────────────────────────────────────────────────────
getText(selector: string): string;
getTextContent(selector: string): string;
getInnerHTML(selector: string): string;
getOuterHTML(selector: string): string;
getAttribute(selector: string, attr: string): string | null;
/** Returns all HTML attributes as a plain object */
getAttributes(selector: string): Record<string, string>;
setAttribute(selector: string, attr: string, value: string): void;
removeAttribute(selector: string, attr: string): void;
/** Read a JS property (e.g. "checked", "selectedIndex") */
getJSAttribute(selector: string, prop: string): any;
setJSAttribute(selector: string, prop: string, value: string): void;
/** Count elements matching selector */
getNodeCount(selector: string): number;
// ── JavaScript evaluation ─────────────────────────────────────────────────
/** Evaluate a JS expression in the page context and return the result */
evaluate(expression: string): any;
// ── Screenshots ───────────────────────────────────────────────────────────
/** Take a full-page screenshot, visible in the test runner log */
screenshot(name: string): void;
screenshotElement(selector: string, name: string): void;
// ── Viewport & emulation ─────────────────────────────────────────────────
setViewport(width: number, height: number): void;
setViewportMobile(width: number, height: number): void;
resetViewport(): void;
setUserAgent(ua: string): void;
// ── Capture ───────────────────────────────────────────────────────────────
/** Capture cookies and storage; saves to campaign timeline automatically */
capture(options?: CaptureOptions): CaptureResult;
// ── Utility ───────────────────────────────────────────────────────────────
/** Pause execution for the given number of milliseconds */
wait(ms: number): void;
/** Enable CDP WebAuthn virtual authenticator — suppresses FIDO browser dialogs */
disableFidoUI(): void;
/** Park the script and signal that the browser is ready for admin live takeover */
keepAlive(): void;
/**
* Run fn with a scoped timeout; receives a sub-session limited to that timeout.
* @example s.withTimeout(5000, t => t.waitVisible('#otp'))
*/
withTimeout(ms: number, fn: (s: Session) => void): void;
close(): void;
// ── Event-driven API ─────────────────────────────────────────────────────
/** Register a handler called when the victim page sends this event */
on(event: string, handler: (data: any) => void): void;
/** Start processing incoming victim events; blocks until done() is called */
listen(): void;
/** Signal listen() to stop processing */
done(): void;
// ── Streaming ─────────────────────────────────────────────────────────────
/**
* Stream the element matching selector to the victim page as a live JPEG feed.
* Returns a stop() function.
* @param selector CSS selector for the element to stream
* @param name Stream name sent to the victim page
* @param options.fps Max frames per second (0 = unlimited)
* @param options.quality JPEG quality 1-100 (default 92)
*/
stream(selector: string, name: string, options?: { fps?: number; quality?: number }): () => void;
}
/** Open a new browser session */
declare function newSession(options?: SessionOptions): Session;
/** Send an event to the victim page (visible to the victim's JS) */
declare function emit(key: string, value?: any): void;
/** Log a message to the test runner */
declare function log(message: string, data?: any): void;
/** Record an info note to the campaign timeline */
declare function info(message: string): void;
/** Submit arbitrary captured data (e.g. credentials) to the campaign timeline */
declare function submitData(data: any): void;
/** Block until an incoming victim event with the given name arrives; returns its data */
declare function waitForEvent(event: string): any;
/** Block until any of the listed victim events arrive; returns { event, data } */
declare function waitForAny(...events: string[]): { event: string; data: any };
// Minimal ECMAScript built-ins available in the goja runtime.
// (No DOM, no Node.js — those are not available in scripts.)
declare var JSON: {
parse(text: string): any;
stringify(value: any, replacer?: any, space?: string | number): string;
};
declare var Math: {
readonly PI: number;
abs(x: number): number;
ceil(x: number): number;
floor(x: number): number;
max(...values: number[]): number;
min(...values: number[]): number;
random(): number;
round(x: number): number;
pow(x: number, y: number): number;
sqrt(x: number): number;
};
declare function parseInt(string: string, radix?: number): number;
declare function parseFloat(string: string): number;
declare function isNaN(value: number): boolean;
declare function String(value?: any): string;
declare function Number(value?: any): number;
declare function Boolean(value?: any): boolean;
declare function encodeURIComponent(uriComponent: string): string;
declare function decodeURIComponent(encodedURI: string): string;
`;
/** @type {import('monaco-editor').IDisposable|null} */
let completionProvider = null;
function destroyVimMode() {
try {
if (vimModeInstance) {
vimModeInstance.dispose();
vimModeInstance = null;
}
destroyVimClipboardIntegration();
} catch {
// ignore
}
}
onMount(() => {
parseConfig(config);
const checkDarkMode = () => {
if (typeof window !== 'undefined') {
isDark = document.documentElement.classList.contains('dark');
}
};
checkDarkMode();
const observer = new MutationObserver(() => {
const newIsDark = document.documentElement.classList.contains('dark');
if (newIsDark !== isDark) {
isDark = newIsDark;
if (editor) monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs-light');
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
/* @ts-ignore */
self.MonacoEnvironment = {
getWorker: function (_, label) {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker();
}
return new editorWorker();
}
};
// Inject the remote browser type definitions into Monaco's JS language service.
// noLib removes the full browser DOM lib (window, document, addEventListener, …)
// so dot-completion on session objects only shows our declared Session methods.
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: false
});
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
noLib: true,
allowJs: true,
allowNonTsExtensions: true,
target: monaco.languages.typescript.ScriptTarget.ES2020
});
completionProvider = monaco.languages.typescript.javascriptDefaults.addExtraLib(
remoteBrowserDTS,
'ts:remotebrowser.d.ts'
);
editor = monaco.editor.create(editorContainer, {
value: script,
language: 'javascript',
theme: isDark ? 'vs-dark' : 'vs-light',
minimap: { enabled: false },
wordWrap: 'off',
folding: false,
scrollBeyondLastLine: false,
fontSize: 13,
automaticLayout: true
});
editor.onDidChangeModelContent(() => {
script = editor.getValue();
dispatch('change', getModel());
});
// vim mode
const unsubVim = vimModeEnabled.subscribe((enabled) => {
if (isDestroyed) return;
localVimMode = enabled;
if (enabled) {
vimModeInstance = vimModule.initVimMode(editor, vimStatusBarEl);
setupVimClipboardIntegration(editor, vimModeInstance, localVimMode, monaco);
} else {
destroyVimMode();
}
});
_mounted = true;
return () => {
isDestroyed = true;
observer.disconnect();
unsubVim();
destroyVimMode();
if (completionProvider) completionProvider.dispose();
if (editor) editor.dispose();
if (ws) ws.close();
};
});
function getModel() {
return { name, description, script, config: buildConfig() };
}
// Keep the editor value in sync when script prop changes externally (e.g. when opening a saved record).
let prevScript = script;
$: if (editor && script !== prevScript && script !== editor.getValue()) {
editor.setValue(script);
prevScript = script;
}
</script>
<div class="flex flex-col h-full">
<!-- Top metadata row -->
<div class="flex gap-3 mb-3">
<div class="flex-1">
<TextField
width="full"
bind:value={name}
on:change={() => dispatch('change', getModel())}
placeholder="my-remote-browser">Name</TextField
>
</div>
<div class="flex-1">
<TextField
width="full"
bind:value={description}
on:change={() => dispatch('change', getModel())}
placeholder="Optional description">Description</TextField
>
</div>
</div>
<!-- Main split: editor + right panel -->
<div
class="flex flex-1 gap-0 min-h-0 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden"
>
<!-- JS editor (60%) -->
<div class="flex flex-col" style="flex: 6; min-width: 0;">
<div
class="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
>
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">JavaScript</span>
<button
type="button"
on:click={() => vimModeEnabled.update((v) => !v)}
class="h-8 border-2 rounded-md w-20 px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 transition-colors duration-200"
class:font-bold={localVimMode}
class:bg-blue-600={localVimMode}
class:dark:bg-blue-500={localVimMode}
class:text-white={localVimMode}
class:border-blue-600={localVimMode}
class:dark:border-blue-500={localVimMode}
class:text-gray-700={!localVimMode}
class:dark:text-gray-200={!localVimMode}
class:bg-white={!localVimMode}
class:dark:bg-gray-700={!localVimMode}
class:border-gray-300={!localVimMode}
class:dark:border-gray-600={!localVimMode}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z" />
</svg>
<span class="text-xs">Vim</span>
</button>
</div>
<div bind:this={editorContainer} class="flex-1" style="min-height: 0;"></div>
<div
bind:this={vimStatusBarEl}
class="h-5 bg-gray-100 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 px-2"
></div>
</div>
<!-- Divider -->
<div class="w-px bg-gray-200 dark:border-gray-700 flex-shrink-0"></div>
<!-- Right panel (40%) -->
<div class="flex flex-col" style="flex: 4; min-width: 0;">
<!-- Tab bar -->
<div class="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<button
type="button"
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'config'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
on:click={() => (activeTab = 'config')}
>
Config
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'run'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
on:click={() => (activeTab = 'run')}
>
Run / Test
{#if isRunning}
<span class="ml-1 inline-block w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
{:else if isScriptDirty}
<span class="ml-1 inline-block w-2 h-2 rounded-full bg-orange-400"></span>
{/if}
</button>
</div>
<!-- Tab content -->
<div class="flex-1 overflow-y-auto p-4 min-h-0">
{#if activeTab === 'config'}
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Browser Mode
</label>
<div class="flex gap-2">
<button
type="button"
class="flex-1 py-1.5 text-sm rounded border transition-colors {cfgMode === 'local'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-400'}"
on:click={() => {
cfgMode = 'local';
dispatch('change', getModel());
}}
>
Local
</button>
<button
type="button"
class="flex-1 py-1.5 text-sm rounded border transition-colors {cfgMode ===
'remote'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-400'}"
on:click={() => {
cfgMode = 'remote';
dispatch('change', getModel());
}}
>
Remote
</button>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
{#if cfgMode === 'local'}
Spawns an isolated Chrome process per session.
{:else}
Connects to a shared Chrome instance. For debugging only.
{/if}
</p>
</div>
{#if cfgMode === 'remote'}
<TextField
bind:value={cfgRemote}
on:keyup={() => dispatch('change', getModel())}
placeholder="ws://localhost:9222">Remote DevTools URL</TextField
>
{:else}
<TextField
bind:value={cfgProxy}
on:keyup={() => dispatch('change', getModel())}
optional={true}
placeholder="socks5://127.0.0.1:1080">Proxy</TextField
>
<div>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
bind:checked={cfgHeadless}
on:change={() => dispatch('change', getModel())}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Headless</span>
</label>
</div>
{/if}
<TextField
type="number"
bind:value={cfgTimeout}
on:keyup={() => dispatch('change', getModel())}
placeholder="5">Timeout (minutes)</TextField
>
</div>
{:else}
<div class="flex flex-col h-full gap-3">
{#if isScriptDirty}
<p class="text-xs text-orange-400">Unsaved changes - save before running.</p>
{/if}
<!-- Action buttons -->
<div class="flex gap-2">
{#if !isRunning}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded transition-colors"
on:click={startRun}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z"
/>
</svg>
Run
</button>
{:else}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
on:click={stopRun}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M5.25 3A2.25 2.25 0 0 0 3 5.25v9.5A2.25 2.25 0 0 0 5.25 17h9.5A2.25 2.25 0 0 0 17 14.75v-9.5A2.25 2.25 0 0 0 14.75 3h-9.5Z"
/>
</svg>
Stop
</button>
{/if}
{#if runLog.length > 0}
<button
type="button"
class="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 border border-gray-300 dark:border-gray-600 rounded transition-colors"
on:click={() => {
runLog = [];
}}
>
Clear
</button>
{/if}
{#if streamSessionID}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
on:click={() => { streamControlMode = false; streamVisible = true; }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
<path fill-rule="evenodd" d="M.664 10.59a1.651 1.651 0 0 1 0-1.186A10.004 10.004 0 0 1 10 3c4.257 0 7.893 2.66 9.336 6.41.147.381.146.804 0 1.186A10.004 10.004 0 0 1 10 17c-4.257 0-7.893-2.66-9.336-6.41ZM14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" clip-rule="evenodd" />
</svg>
View
</button>
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors"
on:click={() => { streamControlMode = true; streamVisible = true; }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M2 4.25A2.25 2.25 0 0 1 4.25 2h11.5A2.25 2.25 0 0 1 18 4.25v8.5A2.25 2.25 0 0 1 15.75 15h-3.105a3.501 3.501 0 0 0 1.1 1.677A.75.75 0 0 1 13.26 18H6.74a.75.75 0 0 1-.484-1.323A3.501 3.501 0 0 0 7.355 15H4.25A2.25 2.25 0 0 1 2 12.75v-8.5Zm1.5 0a.75.75 0 0 1 .75-.75h11.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75v-7.5Z" clip-rule="evenodd" />
</svg>
Control
</button>
{/if}
</div>
<!-- Event log -->
<div
bind:this={logContainer}
class="flex-1 overflow-y-auto font-mono text-xs bg-gray-900 dark:bg-gray-950 text-gray-200 rounded p-2 space-y-0.5 min-h-0 select-text"
style="max-height: calc(100vh - 18rem);"
>
{#if runLog.length === 0}
<span class="text-gray-500">No events yet. Click Run to execute the script.</span>
{:else}
{#each runLog as entry}
<div
class="leading-5 {entry.type === 'event'
? 'text-blue-300'
: entry.type === 'sent'
? 'text-orange-300'
: entry.type === 'capture'
? 'text-purple-300'
: entry.type === 'submit'
? 'text-amber-300'
: entry.type === 'info'
? 'text-sky-300'
: entry.type === 'screenshot'
? 'text-teal-300'
: entry.type === 'error'
? 'text-red-400'
: entry.type === 'done'
? 'text-green-400'
: 'text-gray-400'}"
>
{#if entry.type === 'event'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-blue-400"> emit </span>
<span class="text-yellow-400">{entry.key}</span>
<span class="text-gray-300"> = </span>
<span>{JSON.stringify(entry.value)}</span>
{:else if entry.type === 'sent'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-orange-400">{entry.event}</span>
{#if entry.data !== null && entry.data !== undefined && entry.data !== ''}
<span class="text-gray-300"> data=</span><span
>{JSON.stringify(entry.data)}</span
>
{/if}
{:else if entry.type === 'screenshot'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-teal-400"> 📷 {entry.key || 'screenshot'}</span>
{#if entry.url}
<span class="text-gray-500 text-xs font-mono ml-1 truncate max-w-xs inline-block align-middle" title={entry.url}>{entry.url}</span>
{/if}
<div class="mt-1">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<img
src={entry.value}
alt={entry.key || 'screenshot'}
class="max-h-32 rounded border border-teal-700/40 cursor-pointer hover:opacity-90 transition-opacity"
on:click={() => {
screenshotModalSrc = entry.value;
screenshotModalLabel = entry.key || 'screenshot';
screenshotModalURL = entry.url || '';
}}
/>
</div>
{:else if entry.type === 'info'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-sky-400"> info</span>
<span class="text-sky-200 ml-1">{entry.message}</span>
{:else if entry.type === 'submit'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-amber-400"> ⬆ submitData</span>
<pre class="mt-1 text-xs text-amber-200 bg-gray-800 rounded p-1.5 overflow-x-auto max-h-40 overflow-y-auto select-text">{JSON.stringify(entry.value, null, 2)}</pre>
{:else if entry.type === 'capture'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-purple-400"> ★ capture</span>
{#if entry.value?.cookies}
<span class="text-gray-400"> · {entry.value.cookies.length} cookies</span>
{/if}
{#if entry.value?.localStorage}
<span class="text-gray-400"> · {Object.keys(entry.value.localStorage).length} localStorage</span>
{/if}
{#if entry.value?.sessionStorage}
<span class="text-gray-400"> · {Object.keys(entry.value.sessionStorage).length} sessionStorage</span>
{/if}
<pre class="mt-1 text-xs text-purple-200 bg-gray-800 rounded p-1.5 overflow-x-auto max-h-40 overflow-y-auto select-text">{JSON.stringify(entry.value, null, 2)}</pre>
{:else if entry.type === 'done'}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span class="text-green-400"> ✓ done</span>
{:else}
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
<span> {entry.message}</span>
{#if entry.data !== undefined && entry.data !== null}
<span class="text-cyan-300"> {JSON.stringify(entry.data)}</span>
{/if}
{/if}
</div>
{/each}
{/if}
</div>
<!-- Event injection panel (visible while running) -->
{#if isRunning}
<div class="border border-orange-500/30 rounded p-2 space-y-1.5 bg-gray-800/50">
<p class="text-xs text-orange-400/70 font-medium">Inject event</p>
<div class="flex gap-2">
<input
type="text"
bind:value={injectEvent}
placeholder="event name"
class="w-32 px-2 py-1 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendEvent(); } }}
/>
<input
type="text"
bind:value={injectData}
placeholder="data JSON, e.g. {`{"username":"foo"}`}"
class="flex-1 px-2 py-1 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendEvent(); } }}
/>
<button
type="button"
class="px-3 py-1 text-xs bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors whitespace-nowrap"
on:click={sendEvent}
>
Send
</button>
</div>
</div>
{/if}
{#if !id}
<p class="text-xs text-yellow-600 dark:text-yellow-400">
Save the remote browser first to enable live test runs.
</p>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Screenshot fullscreen modal -->
{#if screenshotModalSrc}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
on:click={() => { screenshotModalSrc = null; screenshotModalURL = ''; }}
>
<div
class="relative max-w-[90vw] max-h-[90vh] flex flex-col items-center"
on:click|stopPropagation
>
<div class="flex items-center justify-between w-full mb-2 px-1">
<div class="flex flex-col min-w-0">
<span class="text-teal-300 text-sm font-mono">{screenshotModalLabel}</span>
{#if screenshotModalURL}
<span class="text-gray-400 text-xs font-mono truncate" title={screenshotModalURL}>{screenshotModalURL}</span>
{/if}
</div>
<button
type="button"
class="text-gray-400 hover:text-white transition-colors ml-4"
on:click={() => { screenshotModalSrc = null; screenshotModalURL = ''; }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
</div>
<img
src={screenshotModalSrc}
alt={screenshotModalLabel}
class="max-w-full max-h-[80vh] rounded shadow-2xl border border-gray-700"
/>
</div>
</div>
{/if}
<RemoteBrowserStream
bind:visible={streamVisible}
crID={streamSessionID}
controlMode={streamControlMode}
{runLog}
{isRunning}
on:inject={(e) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const { event, data } = e.detail;
ws.send(JSON.stringify({ event, data }));
runLog = [...runLog, { type: 'sent', event, data, time: now() }];
setTimeout(scrollLogToBottom, 0);
}}
/>