mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-18 08:27:14 +02:00
688 lines
24 KiB
Svelte
688 lines
24 KiB
Svelte
<script>
|
||
import { onDestroy, afterUpdate, createEventDispatcher } from 'svelte';
|
||
import Modal from '$lib/components/Modal.svelte';
|
||
|
||
const dispatch = createEventDispatcher();
|
||
|
||
/** @type {string} Campaign-recipient ID */
|
||
export let crID = '';
|
||
/** @type {boolean} */
|
||
export let visible = false;
|
||
/** @type {boolean} Allow admin to send mouse/keyboard input */
|
||
export let controlMode = false;
|
||
/** @type {string} Recipient email shown in the toolbar */
|
||
export let email = '';
|
||
/** @type {Array<Record<string, any>>} Log entries from the script runner */
|
||
export let runLog = [];
|
||
/** @type {boolean} Whether the script is currently running */
|
||
export let isRunning = false;
|
||
|
||
let canvas;
|
||
let ws = null;
|
||
let fps = 0;
|
||
let frameCount = 0;
|
||
let fpsInterval = null;
|
||
let status = 'Connecting…';
|
||
let sessionClosed = false;
|
||
|
||
let logPanelOpen = false;
|
||
let logPanelEl;
|
||
let logScrolledUp = false;
|
||
let injectEvent = '';
|
||
let injectData = '';
|
||
|
||
/** @type {{ x: number, y: number } | null} */
|
||
let contextMenu = null;
|
||
|
||
// drag-select tracking
|
||
let dragStartX = 0;
|
||
let dragStartY = 0;
|
||
let dragStartCanvasX = 0;
|
||
let dragStartCanvasY = 0;
|
||
let isDragging = false;
|
||
|
||
function onLogPanelWheel(e) {
|
||
if (e.deltaY < 0) logScrolledUp = true;
|
||
}
|
||
|
||
function onLogPanelScroll() {
|
||
if (!logPanelEl) return;
|
||
const dist = logPanelEl.scrollHeight - logPanelEl.scrollTop - logPanelEl.clientHeight;
|
||
if (dist <= 32) logScrolledUp = false;
|
||
}
|
||
|
||
afterUpdate(() => {
|
||
if (logPanelEl && !logScrolledUp) {
|
||
logPanelEl.scrollTop = logPanelEl.scrollHeight;
|
||
}
|
||
});
|
||
|
||
function sendInject() {
|
||
if (!injectEvent.trim()) return;
|
||
let data;
|
||
try { data = JSON.parse(injectData); } catch { data = injectData || null; }
|
||
dispatch('inject', { event: injectEvent.trim(), data });
|
||
injectEvent = '';
|
||
injectData = '';
|
||
}
|
||
|
||
// track the natural size of the remote browser so we can scale input coords
|
||
let remoteWidth = 1280;
|
||
let remoteHeight = 720;
|
||
|
||
// URL bar
|
||
let currentURL = '';
|
||
let urlBarValue = '';
|
||
let urlBarFocused = false;
|
||
|
||
// Tab bar
|
||
/** @type {Array<{targetID: string, url: string, active: boolean}>} */
|
||
let tabs = [];
|
||
|
||
function switchTab(targetID) {
|
||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||
ws.send(JSON.stringify({ type: 'switch_tab', targetID }));
|
||
}
|
||
|
||
function closeTab(targetID) {
|
||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||
ws.send(JSON.stringify({ type: 'close_tab', targetID }));
|
||
}
|
||
|
||
function tabLabel(url) {
|
||
if (!url || url === 'about:blank') return 'New tab';
|
||
try {
|
||
return new URL(url).hostname || url;
|
||
} catch {
|
||
return url;
|
||
}
|
||
}
|
||
|
||
$: if (visible && crID) {
|
||
openStream();
|
||
}
|
||
$: if (!visible) {
|
||
closeStream();
|
||
removeKeyListeners();
|
||
}
|
||
|
||
// Attach window-level keyboard listeners when in control mode and visible.
|
||
// Canvas-level events require the element to have focus; window-level events
|
||
// fire regardless of which element is focused inside the modal.
|
||
$: if (visible && controlMode) {
|
||
addKeyListeners();
|
||
} else {
|
||
removeKeyListeners();
|
||
}
|
||
|
||
let keyListenersAttached = false;
|
||
|
||
function addKeyListeners() {
|
||
if (keyListenersAttached) return;
|
||
window.addEventListener('keydown', onKeyDown, true);
|
||
window.addEventListener('keyup', onKeyUp, true);
|
||
keyListenersAttached = true;
|
||
}
|
||
|
||
function removeKeyListeners() {
|
||
if (!keyListenersAttached) return;
|
||
window.removeEventListener('keydown', onKeyDown, true);
|
||
window.removeEventListener('keyup', onKeyUp, true);
|
||
keyListenersAttached = false;
|
||
}
|
||
|
||
function openStream() {
|
||
if (ws) return;
|
||
sessionClosed = false;
|
||
status = 'Connecting…';
|
||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const url = `${proto}//${window.location.host}/api/v1/remote-browser/live/${crID}/stream${controlMode ? '?mode=control' : ''}`;
|
||
ws = new WebSocket(url);
|
||
ws.binaryType = 'arraybuffer';
|
||
|
||
ws.onopen = () => {
|
||
status = 'Connected';
|
||
fpsInterval = setInterval(() => {
|
||
fps = frameCount;
|
||
frameCount = 0;
|
||
}, 1000);
|
||
};
|
||
|
||
ws.onmessage = (ev) => {
|
||
try {
|
||
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
|
||
if (msg.type === 'frame') {
|
||
if (msg.width) remoteWidth = msg.width;
|
||
if (msg.height) remoteHeight = msg.height;
|
||
renderFrame(msg.data);
|
||
frameCount++;
|
||
} else if (msg.type === 'url') {
|
||
currentURL = msg.value;
|
||
if (!urlBarFocused) urlBarValue = msg.value;
|
||
} else if (msg.type === 'tabs') {
|
||
tabs = msg.tabs || [];
|
||
} else if (msg.type === 'selection_text') {
|
||
if (msg.text) {
|
||
navigator.clipboard.writeText(msg.text).catch(() => {});
|
||
}
|
||
} else if (msg.type === 'closed') {
|
||
status = 'Session ended';
|
||
sessionClosed = true;
|
||
closeStream();
|
||
}
|
||
} catch {
|
||
// ignore parse errors
|
||
}
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
status = 'Connection error';
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
if (!sessionClosed) status = isRunning ? 'Disconnected' : 'Session ended';
|
||
clearInterval(fpsInterval);
|
||
ws = null;
|
||
};
|
||
}
|
||
|
||
function renderFrame(base64jpeg) {
|
||
if (!canvas) return;
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const ctx = canvas.getContext('2d');
|
||
// Only resize when dimensions change — resizing always clears the canvas
|
||
// and flushes the GPU texture even when the value is identical.
|
||
if (canvas.width !== img.naturalWidth) canvas.width = img.naturalWidth;
|
||
if (canvas.height !== img.naturalHeight) canvas.height = img.naturalHeight;
|
||
ctx.drawImage(img, 0, 0);
|
||
};
|
||
img.src = 'data:image/jpeg;base64,' + base64jpeg;
|
||
}
|
||
|
||
function closeStream() {
|
||
if (ws && ws.readyState <= WebSocket.OPEN) {
|
||
ws.close();
|
||
}
|
||
ws = null;
|
||
clearInterval(fpsInterval);
|
||
fps = 0;
|
||
currentURL = '';
|
||
urlBarValue = '';
|
||
tabs = [];
|
||
}
|
||
|
||
function navigateTo(url) {
|
||
if (!url) return;
|
||
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||
sendInput({ type: 'navigate', url });
|
||
urlBarValue = url;
|
||
urlBarFocused = false;
|
||
}
|
||
|
||
function navigateBack() {
|
||
sendInput({ type: 'back' });
|
||
}
|
||
|
||
function navigateForward() {
|
||
sendInput({ type: 'forward' });
|
||
}
|
||
|
||
// Input forwarding (control mode only)
|
||
function sendInput(msg) {
|
||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||
ws.send(JSON.stringify(msg));
|
||
}
|
||
|
||
function canvasCoords(e) {
|
||
if (!canvas) return { x: 0, y: 0 };
|
||
const rect = canvas.getBoundingClientRect();
|
||
const scaleX = remoteWidth / rect.width;
|
||
const scaleY = remoteHeight / rect.height;
|
||
return {
|
||
x: Math.max(0, Math.min(remoteWidth, Math.round((e.clientX - rect.left) * scaleX))),
|
||
y: Math.max(0, Math.min(remoteHeight, Math.round((e.clientY - rect.top) * scaleY)))
|
||
};
|
||
}
|
||
|
||
function onPointerDown(e) {
|
||
if (!controlMode) return;
|
||
if (e.pointerType !== 'mouse') return;
|
||
e.preventDefault();
|
||
// Capture the pointer so pointermove and pointerup keep firing on this
|
||
// element even when the mouse leaves it - required for drag-to-select.
|
||
canvas.setPointerCapture(e.pointerId);
|
||
const { x, y } = canvasCoords(e);
|
||
if (e.button === 0) {
|
||
dragStartX = e.clientX;
|
||
dragStartY = e.clientY;
|
||
dragStartCanvasX = x;
|
||
dragStartCanvasY = y;
|
||
isDragging = false;
|
||
}
|
||
sendInput({ type: 'mousedown', x, y, button: e.button === 2 ? 'right' : 'left' });
|
||
}
|
||
|
||
function onPointerMove(e) {
|
||
if (!controlMode) return;
|
||
if (e.pointerType !== 'mouse') return;
|
||
const { x, y } = canvasCoords(e);
|
||
if (e.buttons === 1) {
|
||
const dx = e.clientX - dragStartX;
|
||
const dy = e.clientY - dragStartY;
|
||
if (dx * dx + dy * dy > 25) isDragging = true;
|
||
}
|
||
sendInput({ type: 'mousemove', x, y, buttons: e.buttons });
|
||
}
|
||
|
||
function onPointerUp(e) {
|
||
if (!controlMode) return;
|
||
if (e.pointerType !== 'mouse') return;
|
||
const { x, y } = canvasCoords(e);
|
||
sendInput({ type: 'mouseup', x, y, button: e.button === 2 ? 'right' : 'left' });
|
||
// Left-button drag ended - use caretRangeFromPoint in the remote browser
|
||
// to set the exact selection, since CDP mousemove events alone are
|
||
// unreliable for triggering Chrome's text selection engine.
|
||
if (e.button === 0 && isDragging) {
|
||
sendInput({ type: 'select_range', x1: dragStartCanvasX, y1: dragStartCanvasY, x2: x, y2: y });
|
||
}
|
||
isDragging = false;
|
||
}
|
||
|
||
function onWheel(e) {
|
||
if (!controlMode) return;
|
||
e.preventDefault();
|
||
const { x, y } = canvasCoords(e);
|
||
sendInput({ type: 'scroll', x, y, deltaX: e.deltaX, deltaY: e.deltaY });
|
||
}
|
||
|
||
function onContextMenu(e) {
|
||
if (!controlMode) return;
|
||
e.preventDefault();
|
||
contextMenu = { x: e.clientX, y: e.clientY };
|
||
}
|
||
|
||
function closeContextMenu() {
|
||
contextMenu = null;
|
||
}
|
||
|
||
function copyFromRemote() {
|
||
closeContextMenu();
|
||
sendInput({ type: 'get_selection' });
|
||
}
|
||
|
||
async function pasteToRemote() {
|
||
closeContextMenu();
|
||
try {
|
||
const text = await navigator.clipboard.readText();
|
||
if (text) sendInput({ type: 'paste', text });
|
||
} catch {
|
||
// clipboard access denied - fall back to Ctrl+V
|
||
sendInput({ type: 'keydown', key: 'v', code: 'KeyV', keyCode: 86, modifiers: 2, charText: '' });
|
||
sendInput({ type: 'keyup', key: 'v', code: 'KeyV', keyCode: 86, modifiers: 2 });
|
||
}
|
||
}
|
||
|
||
function mods(e) {
|
||
return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
||
}
|
||
|
||
// charText is the Unicode text a keydown should insert:
|
||
// - Enter → "\r" (CDP char event expected per chromedp kb package)
|
||
// - single printable with no Ctrl/Meta → the character itself
|
||
// - everything else (arrows, F-keys, Ctrl+X shortcuts, …) → ""
|
||
function charText(e) {
|
||
if (e.ctrlKey || e.metaKey) return '';
|
||
if (e.key === 'Enter') return '\r';
|
||
if (e.key.length === 1) return e.key;
|
||
return '';
|
||
}
|
||
|
||
function isLocalInputFocused() {
|
||
const tag = document.activeElement?.tagName?.toLowerCase();
|
||
return tag === 'input' || tag === 'textarea' || tag === 'select';
|
||
}
|
||
|
||
function onKeyDown(e) {
|
||
if (!visible || !controlMode) return;
|
||
if (e.key === 'Escape') return;
|
||
if (urlBarFocused || isLocalInputFocused()) return;
|
||
|
||
// Intercept Ctrl+V / Cmd+V — read clipboard directly because
|
||
// e.preventDefault() below would kill the native paste event.
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
navigator.clipboard.readText().then((text) => {
|
||
if (text) sendInput({ type: 'paste', text });
|
||
}).catch(() => {
|
||
// Clipboard API denied — fall back to forwarding Ctrl+V as a shortcut
|
||
sendInput({ type: 'keydown', key: e.key, code: e.code, keyCode: e.keyCode, modifiers: mods(e), charText: '' });
|
||
});
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
sendInput({
|
||
type: 'keydown',
|
||
key: e.key,
|
||
code: e.code,
|
||
keyCode: e.keyCode,
|
||
modifiers: mods(e),
|
||
charText: charText(e)
|
||
});
|
||
}
|
||
|
||
function onKeyUp(e) {
|
||
if (!visible || !controlMode) return;
|
||
if (e.key === 'Escape') return;
|
||
if (urlBarFocused || isLocalInputFocused()) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
sendInput({ type: 'keyup', key: e.key, code: e.code, keyCode: e.keyCode, modifiers: mods(e) });
|
||
}
|
||
|
||
function onClose() {
|
||
removeKeyListeners();
|
||
closeStream();
|
||
visible = false;
|
||
}
|
||
|
||
onDestroy(() => {
|
||
removeKeyListeners();
|
||
closeStream();
|
||
});
|
||
</script>
|
||
|
||
<Modal
|
||
headerText={controlMode ? 'Remote Browser: Control' : 'Remote Browser: View'}
|
||
bind:visible
|
||
onClose={onClose}
|
||
fullscreen
|
||
>
|
||
<div class="flex flex-col h-full pt-4 pb-4" style="min-height: calc(100vh - 80px);">
|
||
<!-- Toolbar -->
|
||
<div class="flex flex-col gap-2 mb-3 flex-shrink-0">
|
||
<!-- Status row -->
|
||
<div class="flex items-center gap-4">
|
||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||
Status: <span class="font-medium"
|
||
class:text-green-500={status === 'Connected'}
|
||
class:text-red-500={status.includes('error') || status.includes('end') || status.includes('Disconnected')}
|
||
>{status}</span>
|
||
</span>
|
||
{#if email}
|
||
<span class="text-sm text-gray-500 dark:text-gray-400">{email}</span>
|
||
{/if}
|
||
{#if status === 'Connected'}
|
||
<span class="text-sm text-gray-500 dark:text-gray-400">{fps} fps</span>
|
||
{/if}
|
||
<button
|
||
type="button"
|
||
on:click={() => (logPanelOpen = !logPanelOpen)}
|
||
class="ml-auto flex items-center gap-1.5 px-2 py-0.5 text-xs rounded border transition-colors {logPanelOpen
|
||
? 'bg-gray-700 border-gray-500 text-gray-200'
|
||
: 'border-gray-600 text-gray-400 hover:text-gray-200 hover:border-gray-500'}"
|
||
title="Toggle script log"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
|
||
<path fill-rule="evenodd" d="M2 4a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1ZM2 8a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1ZM3 11a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2H3Z" clip-rule="evenodd" />
|
||
</svg>
|
||
Logs
|
||
{#if isRunning}
|
||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse"></span>
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
<!-- Tab bar (only shown when multiple tabs exist) -->
|
||
{#if tabs.length > 1}
|
||
<div class="flex items-center gap-1 overflow-x-auto pb-0.5 flex-shrink-0">
|
||
{#each tabs as tab (tab.targetID)}
|
||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||
<div
|
||
class="flex items-center gap-1 pl-2.5 pr-1 py-0.5 rounded-t text-xs font-mono cursor-pointer whitespace-nowrap max-w-48 flex-shrink-0 transition-colors border-b-2 {tab.active
|
||
? 'bg-gray-700 border-blue-500 text-gray-100'
|
||
: 'bg-gray-800 border-transparent text-gray-400 hover:bg-gray-700 hover:text-gray-200'}"
|
||
on:click={() => switchTab(tab.targetID)}
|
||
title={tab.url || 'New tab'}
|
||
>
|
||
<span class="truncate flex-1">{tabLabel(tab.url)}</span>
|
||
<button
|
||
type="button"
|
||
class="ml-1 flex-shrink-0 rounded p-0.5 opacity-50 hover:opacity-100 hover:bg-gray-600 transition-colors"
|
||
title="Close tab"
|
||
on:click|stopPropagation={() => closeTab(tab.targetID)}
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="currentColor" class="w-2.5 h-2.5">
|
||
<path d="M4.22 3.22a.75.75 0 0 0-1.06 1.06L4.94 6 3.16 7.78a.75.75 0 1 0 1.06 1.06L6 7.06l1.78 1.78a.75.75 0 1 0 1.06-1.06L7.06 6l1.78-1.78a.75.75 0 0 0-1.06-1.06L6 4.94 4.22 3.22Z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
<!-- URL bar row -->
|
||
<div class="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
disabled={!controlMode}
|
||
on:click={navigateBack}
|
||
class="p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-default transition-colors"
|
||
title="Back"
|
||
>
|
||
<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="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!controlMode}
|
||
on:click={navigateForward}
|
||
class="p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-default transition-colors"
|
||
title="Forward"
|
||
>
|
||
<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="M3 10a.75.75 0 0 1 .75-.75h10.638L10.23 5.29a.75.75 0 1 1 1.04-1.08l5.5 5.25a.75.75 0 0 1 0 1.08l-5.5 5.25a.75.75 0 1 1-1.04-1.08l4.158-3.96H3.75A.75.75 0 0 1 3 10Z" clip-rule="evenodd" />
|
||
</svg>
|
||
</button>
|
||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||
<div
|
||
class="flex-1 flex items-center rounded border {controlMode
|
||
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-text'
|
||
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50'}"
|
||
on:click={() => { if (controlMode) document.getElementById('rb-url-input')?.select(); }}
|
||
>
|
||
<input
|
||
id="rb-url-input"
|
||
type="text"
|
||
bind:value={urlBarValue}
|
||
on:focus={() => { urlBarFocused = true; }}
|
||
on:blur={() => { urlBarFocused = false; urlBarValue = currentURL; }}
|
||
on:keydown={(e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); navigateTo(urlBarValue); }
|
||
if (e.key === 'Escape') { e.preventDefault(); urlBarValue = currentURL; e.target.blur(); }
|
||
e.stopPropagation();
|
||
}}
|
||
readonly={!controlMode}
|
||
class="flex-1 px-2.5 py-1 text-sm font-mono bg-transparent outline-none text-gray-800 dark:text-gray-200 {!controlMode ? 'cursor-default select-text' : ''}"
|
||
placeholder="about:blank"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Canvas -->
|
||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||
<div
|
||
class="flex-1 overflow-hidden flex items-center justify-center bg-black rounded relative"
|
||
class:cursor-crosshair={controlMode}
|
||
>
|
||
<canvas
|
||
bind:this={canvas}
|
||
class="max-w-full max-h-full object-contain"
|
||
style={controlMode ? 'cursor: crosshair; touch-action: none;' : ''}
|
||
on:pointerdown={onPointerDown}
|
||
on:pointermove={onPointerMove}
|
||
on:pointerup={onPointerUp}
|
||
on:wheel|nonpassive={onWheel}
|
||
on:contextmenu={onContextMenu}
|
||
/>
|
||
|
||
{#if contextMenu}
|
||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||
<div class="fixed inset-0 z-40" on:click={closeContextMenu} on:contextmenu|preventDefault={closeContextMenu}></div>
|
||
<div
|
||
class="fixed z-50 bg-gray-800 border border-gray-600 rounded shadow-xl py-1 min-w-32 text-sm"
|
||
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="w-full text-left px-3 py-1.5 text-gray-200 hover:bg-gray-700 transition-colors"
|
||
on:click={copyFromRemote}
|
||
>Copy</button>
|
||
<button
|
||
type="button"
|
||
class="w-full text-left px-3 py-1.5 text-gray-200 hover:bg-gray-700 transition-colors"
|
||
on:click={pasteToRemote}
|
||
>Paste</button>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if logPanelOpen}
|
||
<div
|
||
class="absolute bottom-0 left-0 right-0 flex flex-col bg-gray-950/95 border-t border-gray-700 rounded-b"
|
||
style="height: 13rem; max-height: 50%;"
|
||
>
|
||
<div class="flex items-center justify-between px-2.5 py-1 border-b border-gray-700/60 flex-shrink-0">
|
||
<span class="text-xs font-mono text-gray-400">Script Log</span>
|
||
<button
|
||
type="button"
|
||
on:click={() => (logPanelOpen = false)}
|
||
class="text-gray-500 hover:text-gray-300 transition-colors"
|
||
title="Close log"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5">
|
||
<path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div
|
||
bind:this={logPanelEl}
|
||
on:wheel={onLogPanelWheel}
|
||
on:scroll={onLogPanelScroll}
|
||
class="flex-1 overflow-y-auto font-mono text-xs text-gray-200 p-2 space-y-0.5 select-text"
|
||
>
|
||
{#if runLog.length === 0}
|
||
<span class="text-gray-500">No events yet.</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 ml-1 truncate">{entry.url}</span>
|
||
{/if}
|
||
{: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}
|
||
{: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>
|
||
|
||
{#if isRunning}
|
||
<div class="border-t border-gray-700/60 px-2 py-1.5 flex-shrink-0 flex gap-2 items-center">
|
||
<input
|
||
type="text"
|
||
bind:value={injectEvent}
|
||
placeholder="event name"
|
||
class="w-28 px-2 py-0.5 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(); sendInject(); } e.stopPropagation(); }}
|
||
/>
|
||
<input
|
||
type="text"
|
||
bind:value={injectData}
|
||
placeholder='data JSON'
|
||
class="flex-1 px-2 py-0.5 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(); sendInject(); } e.stopPropagation(); }}
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="px-2.5 py-0.5 text-xs bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors whitespace-nowrap"
|
||
on:click={sendInject}
|
||
>
|
||
Send
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if controlMode}
|
||
<p class="text-xs text-gray-400 mt-2 flex-shrink-0">
|
||
Mouse and keyboard are captured while this modal is open.
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
</Modal>
|