mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-04 06:26:51 +02:00
1599 lines
65 KiB
Svelte
1599 lines
65 KiB
Svelte
<script>
|
||
import { onMount, afterUpdate, 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)
|
||
let cfgLang = ''; // BCP 47 locale, e.g. "en-US" (local mode only)
|
||
let cfgExtraFlags = ''; // one --flag=value per line, passed to Chrome at launch (local mode only)
|
||
|
||
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);
|
||
cfgLang = obj.lang || '';
|
||
cfgExtraFlags = (obj.extraFlags || []).join('\n');
|
||
} catch {
|
||
// keep defaults
|
||
}
|
||
}
|
||
|
||
function buildConfig() {
|
||
return JSON.stringify(
|
||
{
|
||
mode: cfgMode,
|
||
remote: cfgRemote,
|
||
proxy: cfgProxy,
|
||
headless: cfgHeadless,
|
||
timeout: cfgTimeout * 60000,
|
||
lang: cfgLang,
|
||
extraFlags: cfgExtraFlags.split('\n').map(s => s.trim()).filter(Boolean)
|
||
},
|
||
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;
|
||
let userScrolledUp = false;
|
||
|
||
// 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() }];
|
||
injectEvent = '';
|
||
injectData = '';
|
||
}
|
||
|
||
function onLogWheel(e) {
|
||
if (e.deltaY < 0) userScrolledUp = true;
|
||
}
|
||
|
||
function onLogScroll() {
|
||
if (!logContainer) return;
|
||
const distFromBottom = logContainer.scrollHeight - logContainer.scrollTop - logContainer.clientHeight;
|
||
if (distFromBottom <= 32) userScrolledUp = false;
|
||
}
|
||
|
||
afterUpdate(() => {
|
||
if (logContainer && !userScrolledUp) {
|
||
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 = [];
|
||
userScrolledUp = false;
|
||
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];
|
||
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;
|
||
/** Stream Chrome process stdout/stderr into the event log (noisy; for crash diagnostics only) */
|
||
chromeDebug?: 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;
|
||
/**
|
||
* BCP 47 locale for Chrome's language setting, e.g. "en-US" or "en-GB".
|
||
* Sets navigator.language, navigator.languages, and the Accept-Language header
|
||
* at the process level — consistent across the main frame AND Web Workers.
|
||
* Local mode only; ignored when connecting to a remote browser.
|
||
* Prefer this over patching navigator.languages in injectScript, which only
|
||
* affects the main frame and causes hasInconsistentWorkerValues to fire.
|
||
*/
|
||
lang?: string;
|
||
/**
|
||
* Additional Chrome CLI flags passed at launch. Each entry must start with "--".
|
||
* Local mode only. Example: ["--use-gl=egl", "--disable-features=VizDisplayCompositor"]
|
||
* Full flag reference: https://peter.sh/experiments/chromium-command-line-switches/
|
||
*/
|
||
extraFlags?: 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 WaitOptions {
|
||
/**
|
||
* Set to false to search only the main page document (skip iframes).
|
||
* Default: true (main page + all iframes, including cross-origin OOPIFs).
|
||
*/
|
||
frames?: boolean;
|
||
/**
|
||
* CSS selector for a specific iframe element. When set, only that iframe's
|
||
* document is searched (instead of all frames). Implies frames:true.
|
||
*/
|
||
frame?: string;
|
||
}
|
||
|
||
interface RaceCondition {
|
||
/** Element is visible (non-zero bounding box) */
|
||
visible?: string;
|
||
/** Element is visible and not disabled */
|
||
ready?: string;
|
||
/** Element exists in DOM */
|
||
present?: string;
|
||
/** Element is not disabled */
|
||
enabled?: string;
|
||
/** Element is not visible */
|
||
notVisible?: string;
|
||
/** Element is absent from DOM */
|
||
notPresent?: string;
|
||
/** Page URL contains this substring */
|
||
urlContains?: string;
|
||
/** Page URL matches this regex */
|
||
urlMatch?: RegExp;
|
||
/** Victim sends this event */
|
||
event?: string;
|
||
/**
|
||
* Fire after this many milliseconds regardless of other conditions.
|
||
* Use as a timeout arm inside race() instead of wrapping the whole call in withTimeout.
|
||
* @example
|
||
* var r = s.race({ ok: { urlContains: '/success' }, timeout: { after: 5000 } });
|
||
* if (r.key === 'timeout') { ... }
|
||
*/
|
||
after?: number;
|
||
}
|
||
|
||
/** Controls the loop started by run(). Passed to each action. */
|
||
interface RunLoop {
|
||
/** Name of the state currently being handled */
|
||
state: string;
|
||
/** End the loop after the current action finishes. Works from any callback. */
|
||
stop(): void;
|
||
}
|
||
|
||
interface RunOptions {
|
||
/** Milliseconds to wait each cycle for the state to change (default 10000) */
|
||
detectTimeout?: number;
|
||
/** Overall budget in milliseconds for the whole loop (0 = no limit) */
|
||
timeout?: number;
|
||
}
|
||
|
||
/** A state machine built by s.states(rules). Add before()/after() hooks, then run(). */
|
||
interface StateMachine {
|
||
/** Register a callback run just before each detected step's action. Receives the state name. */
|
||
before(fn: (state: string) => void): StateMachine;
|
||
/** Register a callback run just after each step's action. Receives the state name. */
|
||
after(fn: (state: string) => void): StateMachine;
|
||
/**
|
||
* Run the loop: detect the state, run its action, then wait for the state to
|
||
* change and run the next action. Each action receives the loop control. An
|
||
* action ends the loop by returning false or calling loop.stop(). The built
|
||
* in "timeout" state fires when the state does not change within detectTimeout.
|
||
*/
|
||
run(actions: { [state: string]: (loop: RunLoop) => any }, options?: RunOptions): 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;
|
||
/** Blocks until the page URL contains the given substring; returns the full URL */
|
||
waitURLContains(substring: string): string;
|
||
/** Blocks until the page URL matches the given regex; returns the full URL */
|
||
waitURLMatch(pattern: RegExp): string;
|
||
|
||
// ── Waiting ───────────────────────────────────────────────────────────────
|
||
/**
|
||
* Wait until any selector is visible (non-zero bounding box). Returns the matched selector.
|
||
* Searches the main page and all iframes by default (including cross-origin OOPIFs).
|
||
* Pass {frames:false} as the last argument to skip iframes.
|
||
* Pass {frame:"iframe#id"} to scope the search to one specific iframe.
|
||
*/
|
||
waitVisible(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
/**
|
||
* Wait until any selector is visible and not disabled. Returns the matched selector.
|
||
* Accepts the same optional {frames, frame} trailing argument as waitVisible.
|
||
*/
|
||
waitReady(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
/**
|
||
* Wait until any selector is not disabled. Returns the matched selector.
|
||
* Accepts the same optional {frames, frame} trailing argument as waitVisible.
|
||
*/
|
||
waitEnabled(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
/**
|
||
* Wait until any selector has a selected option. Returns the matched selector.
|
||
* Accepts the same optional {frames, frame} trailing argument as waitVisible.
|
||
*/
|
||
waitSelected(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
/**
|
||
* Wait until any selector is no longer visible. Returns the matched selector.
|
||
* Accepts the same optional {frames, frame} trailing argument as waitVisible.
|
||
*/
|
||
waitNotVisible(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
/**
|
||
* Wait until any selector is absent from the DOM. Returns the matched selector.
|
||
* Accepts the same optional {frames, frame} trailing argument as waitVisible.
|
||
*/
|
||
waitNotPresent(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
|
||
// ── Mouse ─────────────────────────────────────────────────────────────────
|
||
click(selector: string): void;
|
||
doubleClick(selector: string): void;
|
||
/** Right-click an element by selector, triggering its context menu. */
|
||
rightClick(selector: string): void;
|
||
/** Right-click at absolute page coordinates. */
|
||
rightClickXY(x: number, y: number): void;
|
||
/**
|
||
* Select all text content of an element.
|
||
* Works on inputs/textareas (uses .select()) and general DOM nodes (uses Selection API).
|
||
*/
|
||
selectText(selector: string): void;
|
||
/**
|
||
* Move to absolute coordinates along a curved Bezier path with micro-jitter
|
||
* before clicking. Prefer this over bare clickXY when bot detection is a concern.
|
||
*/
|
||
clickXY(x: number, y: number): void;
|
||
/**
|
||
* Smoothly move the mouse to absolute page coordinates without clicking.
|
||
* Uses a cubic Bezier curve with ease-in-out timing and micro-jitter.
|
||
* @param opts.duration Movement duration in ms. Default: random 200-400 ms.
|
||
* @param opts.jitter Jitter amplitude in px (0 = none). Default: 1.5 px.
|
||
*/
|
||
moveMouse(x: number, y: number, opts?: { duration?: number; jitter?: number }): void;
|
||
scrollIntoView(selector: string): void;
|
||
/**
|
||
* Scroll the page by deltaY CSS pixels (positive = down) using eased,
|
||
* jittered mouse-wheel steps instead of an instant jump.
|
||
*/
|
||
humanScroll(deltaY: number, options?: { duration?: number }): void;
|
||
/**
|
||
* Spend about ms milliseconds producing low-amplitude pointer drift and
|
||
* pauses, the way a person rests a hand on the mouse. Builds natural
|
||
* behavioural signal on pages that treat inactivity as a bot tell.
|
||
*/
|
||
humanIdle(ms: number): 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;
|
||
/** True when the selector matches at least one node */
|
||
present(selector: string): boolean;
|
||
/** True when the first match is rendered and not hidden (instant, unlike waitVisible) */
|
||
visible(selector: string): boolean;
|
||
/** Value of a URL query parameter from the current page, decoded, or null */
|
||
query(name: string): string | null;
|
||
|
||
// ── JavaScript evaluation ─────────────────────────────────────────────────
|
||
/** Evaluate a JS expression in the page context and return the result */
|
||
evaluate(expression: string): any;
|
||
|
||
// ── Screenshots (test/debug only) ─────────────────────────────────────────
|
||
/** Test/debug only. Full page screenshot shown in the editor runner panel.
|
||
* Discarded in live campaign sessions and never saved as a recipient event. */
|
||
screenshot(name: string): void;
|
||
/** Test/debug only. Screenshot of one element, shown in the editor runner
|
||
* panel. Discarded in live campaign sessions. */
|
||
screenshotElement(selector: string, name: string): void;
|
||
/** Test/debug only. Captures the full page HTML and emits it as a named
|
||
* dom_dump event in the editor runner panel. Discarded in live campaign
|
||
* sessions and never saved as a recipient event. */
|
||
domDump(name: string): void;
|
||
|
||
// ── Viewport & emulation ─────────────────────────────────────────────────
|
||
setViewport(width: number, height: number): void;
|
||
setViewportMobile(width: number, height: number): void;
|
||
resetViewport(): void;
|
||
setUserAgent(ua: string): void;
|
||
/**
|
||
* Override the Accept-Language HTTP header and navigator.language/languages in the main frame via CDP.
|
||
* For worker-consistent language signals, use the lang option in newSession() instead.
|
||
* Useful in remote mode where the --lang launch flag cannot be set.
|
||
*/
|
||
setAcceptLanguage(lang: string): void;
|
||
|
||
// ── Header rewriting ────────────────────────────────────────────────────
|
||
// Rewrite HTTP headers on the traffic between the remote browser and the sites
|
||
// it talks to. Rules run over the CDP Fetch domain without re-fetching, so the
|
||
// browser still loads each response itself and the connection is not disturbed.
|
||
// targets is an optional URL glob ("*" matches any run of characters) or an
|
||
// array of globs; omitted means every request. Call before navigate().
|
||
// Framing and routing headers (Content-Length, Transfer-Encoding, Host,
|
||
// Connection, HTTP/2 pseudo-headers) are rejected because editing them breaks
|
||
// the connection.
|
||
/**
|
||
* Add or overwrite a request header on the way to the site.
|
||
* @example s.setRequestHeader("X-Debug", "1", "*login.example.com*")
|
||
*/
|
||
setRequestHeader(name: string, value: string, targets?: string | string[]): void;
|
||
/** Drop a request header before it reaches the site. */
|
||
removeRequestHeader(name: string, targets?: string | string[]): void;
|
||
/** Add or overwrite a response header before the browser sees it. */
|
||
setResponseHeader(name: string, value: string, targets?: string | string[]): void;
|
||
/**
|
||
* Drop a response header before the browser sees it.
|
||
* @example s.removeResponseHeader("X-FooBar")
|
||
*/
|
||
removeResponseHeader(name: string, targets?: string | 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;
|
||
/** Make passkey logins fail fast so the page offers another sign in method and stays usable */
|
||
failFido(): void;
|
||
/** @deprecated use failFido(); kept for existing scripts */
|
||
disableFidoUI(): void;
|
||
/**
|
||
* Register a JS snippet that runs before any page scripts on every subsequent navigation.
|
||
* Scoped to this page only. Call before navigate() so the injection is active on the first load.
|
||
* Use this to normalise fingerprint signals (speechSynthesis, WebGL renderer, etc.) before
|
||
* bot-detection probes fire.
|
||
* @example s.injectScript("Object.defineProperty(navigator,'languages',{get:()=>['en-US','en']})")
|
||
*/
|
||
injectScript(js: string): 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.
|
||
* Returns true if fn completed before the deadline, false if it timed out.
|
||
* @example s.withTimeout(5000, t => t.waitVisible('#otp'))
|
||
*/
|
||
withTimeout(ms: number, fn: (s: Session) => void): boolean;
|
||
close(): void;
|
||
|
||
// ── State machine ─────────────────────────────────────────────────────────
|
||
/**
|
||
* Build a state machine from detection rules (state name -> matcher).
|
||
* Returns the machine; add before()/after() hooks and call run() on it.
|
||
*/
|
||
states(rules: { [state: string]: () => boolean }): StateMachine;
|
||
/**
|
||
* Return the current page state name for the given rules, or "timeout" if none
|
||
* match within timeoutMs (default 10000). Use it to run your own loop.
|
||
*/
|
||
waitForState(rules: { [state: string]: () => boolean }, timeoutMs?: number): string;
|
||
|
||
// ── Event-driven API ─────────────────────────────────────────────────────
|
||
/**
|
||
* Register a handler for a named event. Must be called before s.listen().
|
||
*
|
||
* Built-in lifecycle events emitted automatically by the server:
|
||
* - \`"disconnect"\` - victim closed their browser or navigated away; no data
|
||
* - \`"navigate"\` - main frame navigated to a new URL; data: \`{ url: string }\`
|
||
*
|
||
* All other event names are victim-page events sent via \`rb.emit(name, data)\`.
|
||
*/
|
||
on(event: string, handler: (data: any) => void): void;
|
||
/** Start processing incoming events; blocks until done() is called */
|
||
listen(): void;
|
||
/** Exit the listen() loop */
|
||
done(): void;
|
||
|
||
// ── Race ──────────────────────────────────────────────────────────────────
|
||
/**
|
||
* Races DOM conditions, URL changes, and incoming victim events simultaneously.
|
||
* Returns { key, value } for whichever fires first.
|
||
* DOM condition types: visible, ready, enabled, notVisible, notPresent, present.
|
||
* value is the matched selector (DOM), the full URL (url), or the event payload (event).
|
||
*/
|
||
race(conditions: Record<string, RaceCondition>): { key: string; value: any };
|
||
|
||
// ── Streaming ─────────────────────────────────────────────────────────────
|
||
/**
|
||
* Stream the element matching selector to the victim page as a live JPEG feed.
|
||
* Returns an object with a stop() method.
|
||
* @param selector CSS selector for the element to stream
|
||
* @param name Stream name sent to the victim page
|
||
* @param options.maxFps Max frames per second (0 = unlimited)
|
||
* @param options.quality JPEG quality 1-100 (default 92)
|
||
*/
|
||
stream(selector: string, name: string, options?: { maxFps?: number; quality?: number }): { stop(): void };
|
||
|
||
// ── Frame sub-sessions ────────────────────────────────────────────────────
|
||
/**
|
||
* Scope a sub-session to the iframe matching selector.
|
||
* Returns null if the iframe is not found or cannot be resolved.
|
||
* The sub-session exposes the same DOM, waiting, and interaction methods
|
||
* scoped to that iframe's document. Supports nesting: f.frame("sel").
|
||
* Not available on FrameSession: capture, keepAlive, close, on, listen, done, race, stream.
|
||
* @example
|
||
* var f = s.frame("iframe[src*='accounts.google.com']");
|
||
* if (f) { f.waitVisible("input[type='email']"); f.sendKeys("input[type='email']", "user@example.com"); }
|
||
*/
|
||
frame(selector: string): FrameSession | null;
|
||
}
|
||
|
||
interface FrameSession {
|
||
// ── Navigation ────────────────────────────────────────────────────────────
|
||
navigate(url: string): void;
|
||
navigateBack(): void;
|
||
navigateForward(): void;
|
||
reload(): void;
|
||
stop(): void;
|
||
location(): string;
|
||
title(): string;
|
||
waitURLContains(substring: string): string;
|
||
waitURLMatch(pattern: RegExp): string;
|
||
|
||
// ── Waiting ───────────────────────────────────────────────────────────────
|
||
waitVisible(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
waitReady(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
waitEnabled(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
waitSelected(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
waitNotVisible(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
waitNotPresent(...selectorsAndOpts: Array<string | WaitOptions>): string;
|
||
|
||
// ── Mouse ─────────────────────────────────────────────────────────────────
|
||
click(selector: string): void;
|
||
doubleClick(selector: string): void;
|
||
rightClick(selector: string): void;
|
||
rightClickXY(x: number, y: number): void;
|
||
selectText(selector: string): void;
|
||
clickXY(x: number, y: number): void;
|
||
moveMouse(x: number, y: number, opts?: { duration?: number; jitter?: number }): void;
|
||
scrollIntoView(selector: string): void;
|
||
/**
|
||
* Scroll the page by deltaY CSS pixels (positive = down) using eased,
|
||
* jittered mouse-wheel steps instead of an instant jump.
|
||
*/
|
||
humanScroll(deltaY: number, options?: { duration?: number }): void;
|
||
/**
|
||
* Spend about ms milliseconds producing low-amplitude pointer drift and
|
||
* pauses, the way a person rests a hand on the mouse. Builds natural
|
||
* behavioural signal on pages that treat inactivity as a bot tell.
|
||
*/
|
||
humanIdle(ms: number): void;
|
||
|
||
// ── Keyboard ──────────────────────────────────────────────────────────────
|
||
sendKeys(selector: string, text: string): void;
|
||
keyEvent(key: string): void;
|
||
|
||
// ── Form ──────────────────────────────────────────────────────────────────
|
||
clear(selector: string): void;
|
||
focus(selector: string): void;
|
||
blur(selector: string): void;
|
||
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;
|
||
getAttributes(selector: string): Record<string, string>;
|
||
setAttribute(selector: string, attr: string, value: string): void;
|
||
removeAttribute(selector: string, attr: string): void;
|
||
getJSAttribute(selector: string, prop: string): any;
|
||
setJSAttribute(selector: string, prop: string, value: string): void;
|
||
getNodeCount(selector: string): number;
|
||
|
||
// ── JavaScript evaluation ─────────────────────────────────────────────────
|
||
evaluate(expression: string): any;
|
||
|
||
// ── Screenshots & DOM capture (test/debug only) ───────────────────────────
|
||
/** Test/debug only. Full page screenshot shown in the editor runner panel.
|
||
* Discarded in live campaign sessions and never saved as a recipient event. */
|
||
screenshot(name: string): void;
|
||
/** Test/debug only. Screenshot of one element, shown in the editor runner
|
||
* panel. Discarded in live campaign sessions. */
|
||
screenshotElement(selector: string, name: string): void;
|
||
/** Test/debug only. Captures the full page HTML and emits it as a named
|
||
* dom_dump event in the editor runner panel. Discarded in live campaign
|
||
* sessions and never saved as a recipient event. */
|
||
domDump(name: string): void;
|
||
|
||
// ── Viewport & emulation ─────────────────────────────────────────────────
|
||
setViewport(width: number, height: number): void;
|
||
setViewportMobile(width: number, height: number): void;
|
||
resetViewport(): void;
|
||
setUserAgent(ua: string): void;
|
||
/** Override the Accept-Language HTTP header and navigator.language/languages in the main frame. */
|
||
setAcceptLanguage(lang: string): void;
|
||
|
||
// ── Utility ───────────────────────────────────────────────────────────────
|
||
wait(ms: number): void;
|
||
failFido(): void;
|
||
/** @deprecated use failFido() */
|
||
disableFidoUI(): void;
|
||
/** Register a JS snippet that runs before any page scripts on every subsequent navigation within this frame. */
|
||
injectScript(js: string): void;
|
||
/**
|
||
* Run fn with a scoped timeout inside this frame sub-session.
|
||
* Returns true if fn completed before the deadline, false if it timed out.
|
||
*/
|
||
withTimeout(ms: number, fn: (s: FrameSession) => void): boolean;
|
||
|
||
// ── Nested iframes ────────────────────────────────────────────────────────
|
||
/** Scope a sub-session to a nested iframe within this frame. Returns null if not found. */
|
||
frame(selector: string): FrameSession | null;
|
||
}
|
||
|
||
/** 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;
|
||
/**
|
||
* Stop the script immediately with no error.
|
||
* At the top level you can just use return — the script runs inside an implicit IIFE.
|
||
* Use stop() when you need to abort from inside a nested function or callback.
|
||
*/
|
||
declare function stop(): never;
|
||
/** Block until any of the listed victim events arrive; returns { event, data } */
|
||
declare function waitForAny(events: string[]): { event: string; data: any };
|
||
declare function waitForAny(...events: string[]): { event: string; data: any };
|
||
|
||
interface RetryContext {
|
||
/** Which attempt this is, starting at 1 */
|
||
attempt: number;
|
||
/** The max attempts value passed to retry */
|
||
max: number;
|
||
/** True on attempt 1 */
|
||
isFirst: boolean;
|
||
/** True on the last attempt */
|
||
isLast: boolean;
|
||
}
|
||
/**
|
||
* Calls fn up to max times. Return a truthy value from fn to stop looping; retry returns that value.
|
||
* Return false or nothing from fn to keep looping. Returns null if all attempts are exhausted.
|
||
*/
|
||
declare function retry(max: number, fn: (ctx: RetryContext) => any): any;
|
||
/** Same as retry(max, fn) but also accepts a wait in ms to pause between attempts */
|
||
declare function retry(options: { max: number; wait?: number }, fn: (ctx: RetryContext) => any): any;
|
||
|
||
// ECMAScript built-ins available in the goja runtime (ES2015+).
|
||
// (No DOM, no Node.js — those are not available in scripts.)
|
||
// Instance methods for the string, number and boolean primitives. The String,
|
||
// Number and Boolean call forms are declared further down; these interfaces are
|
||
// what give a string value methods like indexOf, includes and split.
|
||
interface String {
|
||
readonly length: number;
|
||
charAt(index: number): string;
|
||
charCodeAt(index: number): number;
|
||
indexOf(searchValue: string, fromIndex?: number): number;
|
||
lastIndexOf(searchValue: string, fromIndex?: number): number;
|
||
includes(searchValue: string, fromIndex?: number): boolean;
|
||
startsWith(searchValue: string, fromIndex?: number): boolean;
|
||
endsWith(searchValue: string, endPosition?: number): boolean;
|
||
slice(start?: number, end?: number): string;
|
||
substring(start: number, end?: number): string;
|
||
toLowerCase(): string;
|
||
toUpperCase(): string;
|
||
trim(): string;
|
||
padStart(targetLength: number, padString?: string): string;
|
||
padEnd(targetLength: number, padString?: string): string;
|
||
repeat(count: number): string;
|
||
split(separator: string | RegExp, limit?: number): string[];
|
||
replace(searchValue: string | RegExp, replaceValue: string): string;
|
||
replaceAll(searchValue: string | RegExp, replaceValue: string): string;
|
||
match(regexp: string | RegExp): string[] | null;
|
||
concat(...strings: string[]): string;
|
||
[index: number]: string;
|
||
}
|
||
interface Number {
|
||
toFixed(digits?: number): string;
|
||
toString(radix?: number): string;
|
||
valueOf(): number;
|
||
}
|
||
interface Boolean {
|
||
valueOf(): boolean;
|
||
toString(): string;
|
||
}
|
||
declare var JSON: {
|
||
parse(text: string): any;
|
||
stringify(value: any, replacer?: any, space?: string | number): string;
|
||
};
|
||
declare var Math: {
|
||
readonly PI: number;
|
||
readonly E: 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;
|
||
log(x: number): number;
|
||
log2(x: number): number;
|
||
log10(x: number): number;
|
||
trunc(x: number): number;
|
||
sign(x: number): number;
|
||
};
|
||
declare var Object: {
|
||
keys(o: object): string[];
|
||
values(o: object): any[];
|
||
entries(o: object): [string, any][];
|
||
assign<T>(target: T, ...sources: any[]): T;
|
||
create(proto: object | null, props?: object): any;
|
||
defineProperty(o: object, p: string, attrs: object): object;
|
||
getOwnPropertyDescriptor(o: object, p: string): object | undefined;
|
||
getOwnPropertyNames(o: object): string[];
|
||
getPrototypeOf(o: any): object | null;
|
||
freeze<T>(o: T): Readonly<T>;
|
||
fromEntries(entries: Iterable<[string, any]>): object;
|
||
is(value1: any, value2: any): boolean;
|
||
hasOwn(o: object, p: string): boolean;
|
||
new(value?: any): object;
|
||
(value?: any): any;
|
||
};
|
||
declare var Array: {
|
||
isArray(arg: any): arg is any[];
|
||
from<T>(iterable: Iterable<T> | ArrayLike<T>): T[];
|
||
from<T, U>(iterable: Iterable<T> | ArrayLike<T>, mapfn: (v: T, k: number) => U): U[];
|
||
of<T>(...items: T[]): T[];
|
||
new<T>(length?: number): T[];
|
||
<T>(...items: T[]): T[];
|
||
};
|
||
interface Array<T> {
|
||
length: number;
|
||
push(...items: T[]): number;
|
||
pop(): T | undefined;
|
||
shift(): T | undefined;
|
||
unshift(...items: T[]): number;
|
||
slice(start?: number, end?: number): T[];
|
||
splice(start: number, deleteCount?: number, ...items: T[]): T[];
|
||
indexOf(searchElement: T, fromIndex?: number): number;
|
||
includes(searchElement: T, fromIndex?: number): boolean;
|
||
find(predicate: (value: T, index: number, obj: T[]) => boolean): T | undefined;
|
||
findIndex(predicate: (value: T, index: number, obj: T[]) => boolean): number;
|
||
filter(predicate: (value: T, index: number, array: T[]) => boolean): T[];
|
||
map<U>(callbackfn: (value: T, index: number, array: T[]) => U): U[];
|
||
forEach(callbackfn: (value: T, index: number, array: T[]) => void): void;
|
||
reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;
|
||
reduceRight<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;
|
||
some(predicate: (value: T, index: number, array: T[]) => boolean): boolean;
|
||
every(predicate: (value: T, index: number, array: T[]) => boolean): boolean;
|
||
flat<D extends number>(depth?: D): any[];
|
||
flatMap<U>(callbackfn: (value: T, index: number, array: T[]) => U | U[]): U[];
|
||
join(separator?: string): string;
|
||
reverse(): T[];
|
||
sort(compareFn?: (a: T, b: T) => number): T[];
|
||
concat(...items: (T | T[])[]): T[];
|
||
fill(value: T, start?: number, end?: number): T[];
|
||
copyWithin(target: number, start: number, end?: number): T[];
|
||
entries(): IterableIterator<[number, T]>;
|
||
keys(): IterableIterator<number>;
|
||
values(): IterableIterator<T>;
|
||
[n: number]: T;
|
||
[Symbol.iterator](): IterableIterator<T>;
|
||
}
|
||
declare var Date: {
|
||
new(): Date;
|
||
new(value: number | string): Date;
|
||
new(year: number, month: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number): Date;
|
||
now(): number;
|
||
parse(s: string): number;
|
||
UTC(year: number, month: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number): number;
|
||
};
|
||
interface Date {
|
||
getTime(): number;
|
||
getFullYear(): number;
|
||
getMonth(): number;
|
||
getDate(): number;
|
||
getDay(): number;
|
||
getHours(): number;
|
||
getMinutes(): number;
|
||
getSeconds(): number;
|
||
getMilliseconds(): number;
|
||
toISOString(): string;
|
||
toLocaleDateString(): string;
|
||
toLocaleTimeString(): string;
|
||
toLocaleString(): string;
|
||
toString(): string;
|
||
valueOf(): number;
|
||
}
|
||
declare var RegExp: {
|
||
new(pattern: string, flags?: string): RegExp;
|
||
(pattern: string, flags?: string): RegExp;
|
||
};
|
||
interface RegExp {
|
||
test(string: string): boolean;
|
||
exec(string: string): RegExpExecArray | null;
|
||
readonly source: string;
|
||
readonly flags: string;
|
||
readonly global: boolean;
|
||
readonly ignoreCase: boolean;
|
||
readonly multiline: boolean;
|
||
}
|
||
interface RegExpExecArray extends Array<string> {
|
||
index: number;
|
||
input: string;
|
||
}
|
||
declare var Error: {
|
||
new(message?: string): Error;
|
||
(message?: string): Error;
|
||
};
|
||
interface Error {
|
||
message: string;
|
||
name: string;
|
||
stack?: string;
|
||
}
|
||
declare var Map: {
|
||
new<K, V>(entries?: readonly [K, V][]): Map<K, V>;
|
||
};
|
||
interface Map<K, V> {
|
||
clear(): void;
|
||
delete(key: K): boolean;
|
||
forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void): void;
|
||
get(key: K): V | undefined;
|
||
has(key: K): boolean;
|
||
set(key: K, value: V): this;
|
||
readonly size: number;
|
||
entries(): IterableIterator<[K, V]>;
|
||
keys(): IterableIterator<K>;
|
||
values(): IterableIterator<V>;
|
||
}
|
||
declare var Set: {
|
||
new<T>(values?: readonly T[]): Set<T>;
|
||
};
|
||
interface Set<T> {
|
||
add(value: T): this;
|
||
clear(): void;
|
||
delete(value: T): boolean;
|
||
forEach(callbackfn: (value: T, value2: T, set: Set<T>) => void): void;
|
||
has(value: T): boolean;
|
||
readonly size: number;
|
||
entries(): IterableIterator<[T, T]>;
|
||
keys(): IterableIterator<T>;
|
||
values(): IterableIterator<T>;
|
||
}
|
||
declare var Promise: {
|
||
new<T>(executor: (resolve: (value: T) => void, reject: (reason?: any) => void) => void): Promise<T>;
|
||
resolve<T>(value: T): Promise<T>;
|
||
reject(reason?: any): Promise<never>;
|
||
all<T>(values: Promise<T>[]): Promise<T[]>;
|
||
allSettled<T>(values: Promise<T>[]): Promise<Array<{status: string; value?: T; reason?: any}>>;
|
||
};
|
||
interface Promise<T> {
|
||
then<TResult>(onfulfilled?: (value: T) => TResult | Promise<TResult>): Promise<TResult>;
|
||
catch<TResult>(onrejected?: (reason: any) => TResult | Promise<TResult>): Promise<T | TResult>;
|
||
finally(onfinally?: () => void): Promise<T>;
|
||
}
|
||
interface Iterable<T> { [Symbol.iterator](): Iterator<T>; }
|
||
interface IterableIterator<T> extends Iterator<T> { [Symbol.iterator](): IterableIterator<T>; }
|
||
interface Iterator<T> { next(): { done?: boolean; value: T }; }
|
||
interface ArrayLike<T> { readonly length: number; readonly [n: number]: T; }
|
||
declare var Symbol: {
|
||
readonly iterator: unique symbol;
|
||
readonly hasInstance: unique symbol;
|
||
(description?: string): symbol;
|
||
};
|
||
declare function parseInt(string: string, radix?: number): number;
|
||
declare function parseFloat(string: string): number;
|
||
declare function isNaN(value: number): boolean;
|
||
declare function isFinite(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;
|
||
declare function encodeURI(uri: string): string;
|
||
declare function decodeURI(encodedURI: string): string;
|
||
declare var undefined: undefined;
|
||
declare var NaN: number;
|
||
declare var Infinity: number;
|
||
`;
|
||
|
||
/** @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: false,
|
||
noSyntaxValidation: false,
|
||
diagnosticCodesToIgnore: [1108] // 'return' outside function — valid here because the script runs inside an implicit IIFE
|
||
});
|
||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||
noLib: true,
|
||
allowJs: true,
|
||
checkJs: true,
|
||
allowNonTsExtensions: true,
|
||
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||
strict: false
|
||
});
|
||
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"
|
||
title="Toggle vim mode (Ctrl+Alt+V)"
|
||
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>
|
||
<p class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||
Browser Mode
|
||
</p>
|
||
<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}
|
||
Connect to a Chrome you launched yourself - real OS fingerprint, GPU, profile, and extensions. Best for bypassing bot detection.
|
||
{/if}
|
||
</p>
|
||
</div>
|
||
|
||
{#if cfgMode === 'remote'}
|
||
<TextField
|
||
bind:value={cfgRemote}
|
||
on:keyup={() => dispatch('change', getModel())}
|
||
placeholder="http://localhost:9222">Remote DevTools URL</TextField
|
||
>
|
||
<p class="text-xs text-gray-400 dark:text-gray-500 -mt-2">
|
||
Start Chrome with <code class="font-mono">--remote-debugging-port=9222</code> then enter the host:port or full URL here.
|
||
</p>
|
||
{: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>
|
||
<TextField
|
||
bind:value={cfgLang}
|
||
on:keyup={() => dispatch('change', getModel())}
|
||
optional={true}
|
||
placeholder="en-US">Language</TextField
|
||
>
|
||
<div class="flex flex-col">
|
||
<p class="font-semibold text-slate-600 dark:text-gray-400 py-2 transition-colors duration-200 text-sm">
|
||
Flags <span class="font-normal text-xs">(one per line — <code class="font-mono">--flag</code> adds/overrides, <code class="font-mono">!--flag</code> removes)</span>
|
||
</p>
|
||
<textarea
|
||
bind:value={cfgExtraFlags}
|
||
on:keyup={() => dispatch('change', getModel())}
|
||
rows="3"
|
||
placeholder="--use-gl=egl"
|
||
class="font-mono rounded-md py-2 pl-2 text-gray-600 dark:text-gray-300 border focus:outline-none focus:border-solid focus:bg-gray-100 dark:focus:bg-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 transition-colors duration-200 border-transparent dark:border-gray-700/60 focus:border-slate-400 dark:focus:border-highlight-blue/80 resize-y w-full text-sm"
|
||
></textarea>
|
||
</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}
|
||
on:wheel={onLogWheel}
|
||
on:scroll={onLogScroll}
|
||
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}
|
||
showLog={true}
|
||
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() }];
|
||
}}
|
||
/>
|