add page editor auto complete for remote browser variable 'rb'

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-09-06 14:23:39 +02:00
parent 87a8735896
commit 5be512209c
3 changed files with 196 additions and 21 deletions
@@ -1,3 +1,155 @@
<script context="module">
import * as monacoModule from 'monaco-editor';
// Registered once for the whole app. The providers are global to the html
// language, so a module level guard keeps a second editor from adding a
// duplicate. rb is the client that {{RemoteBrowserScript "name"}} injects into
// a phishing page, so the features are only offered inside a <script> block.
let rbFeaturesRegistered = false;
// One source of truth for both completion and hover.
const RB_OBJECT_DETAIL = 'Remote Browser client (window.rb)';
const RB_OBJECT_DOC =
'`rb` (alias `window.rb`). Client injected by `{{RemoteBrowserScript "name"}}`. Connects the phishing page to the remote browser session over WebSocket.\n\nMethods: `on(event, handler)`, `send(event, data)`, `mountStream(name, element)`.';
const RB_MEMBERS = [
{
name: 'on',
detail: '(event: string, handler: (data) => void): void',
insertText: "on('${1:event}', function (data) {\n\t$0\n})",
doc: 'Subscribe to an event the remote browser script sends with `emit()`.\n\nStream form: `rb.on("stream_start", name, handler)` and `rb.on("stream_stop", name, handler)` fire when a named stream starts or stops.'
},
{
name: 'send',
detail: '(event: string, data?: any): void',
insertText: "send('${1:event}'${2:, data})",
doc: 'Send an event and optional data to the running remote browser script. The script receives it through `waitForEvent()` or `s.on()`.'
},
{
name: 'mountStream',
detail: '(name: string, element: HTMLElement, opts?: object): void',
insertText: "mountStream('${1:name}', ${2:element})",
doc: 'Mount a named element stream the script started with `s.stream(selector, name)` into a page element, for example a captcha. Forwards mouse and keyboard input back to the streamed element.'
}
];
// beforeText is the source from the document start up to a point. True when
// that point sits inside an open <script> block.
function rbInScript(beforeText) {
const openIdx = beforeText.lastIndexOf('<script');
const closeIdx = beforeText.lastIndexOf('</script');
return openIdx !== -1 && openIdx >= closeIdx;
}
function ensureRbLanguageFeatures() {
if (rbFeaturesRegistered) return;
rbFeaturesRegistered = true;
const Kind = monacoModule.languages.CompletionItemKind;
const AsSnippet = monacoModule.languages.CompletionItemInsertTextRule.InsertAsSnippet;
monacoModule.languages.registerCompletionItemProvider('html', {
triggerCharacters: ['.'],
provideCompletionItems(model, position) {
const before = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column
});
if (!rbInScript(before)) {
return { suggestions: [] };
}
const lineBefore = before.slice(before.lastIndexOf('\n') + 1);
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
};
// member access on rb / window.rb / remoteBrowser
if (/(?:^|[^\w$.])(?:window\s*\.\s*)?(?:rb|remoteBrowser)\s*\.\s*[\w$]*$/.test(lineBefore)) {
return {
suggestions: RB_MEMBERS.map((m) => ({
label: m.name,
kind: Kind.Method,
insertText: m.insertText,
insertTextRules: AsSnippet,
range,
detail: m.detail,
documentation: { value: m.doc }
}))
};
}
// bare identifier: offer the rb object, but not right after another dot
const uptoWord = lineBefore.slice(0, lineBefore.length - word.word.length);
if (/\.\s*$/.test(uptoWord)) {
return { suggestions: [] };
}
return {
suggestions: [
{
label: 'rb',
kind: Kind.Variable,
insertText: 'rb',
range,
detail: RB_OBJECT_DETAIL,
documentation: { value: RB_OBJECT_DOC }
}
]
};
}
});
monacoModule.languages.registerHoverProvider('html', {
provideHover(model, position) {
const before = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column
});
if (!rbInScript(before)) return null;
const w = model.getWordAtPosition(position);
if (!w) return null;
const lineContent = model.getLineContent(position.lineNumber);
const beforeWord = lineContent.slice(0, w.startColumn - 1);
const range = new monacoModule.Range(
position.lineNumber,
w.startColumn,
position.lineNumber,
w.endColumn
);
// rb / window.rb / remoteBrowser object itself
if (w.word === 'rb' || w.word === 'remoteBrowser') {
const endsWithDot = /\.\s*$/.test(beforeWord);
const isWindowRb = /(?:^|[^\w$])window\s*\.\s*$/.test(beforeWord);
if (endsWithDot && !isWindowRb) return null;
return { range, contents: [{ value: RB_OBJECT_DOC }] };
}
// a member accessed on rb
const m = RB_MEMBERS.find((x) => x.name === w.word);
if (
m &&
/(?:^|[^\w$.])(?:window\s*\.\s*)?(?:rb|remoteBrowser)\s*\.\s*$/.test(beforeWord)
) {
return {
range,
contents: [{ value: '```js\nrb.' + w.word + m.detail + '\n```' }, { value: m.doc }]
};
}
return null;
}
});
}
</script>
<script>
import { onMount, tick } from 'svelte';
import * as monaco from 'monaco-editor';
@@ -462,6 +614,10 @@
return new editorWorker();
}
};
// offer rb client completions in the phishing page editor
if (contentType === 'page') {
ensureRbLanguageFeatures();
}
const editorOptions = {
value: value,
language: 'html',
@@ -440,10 +440,17 @@ interface Session {
/** 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 */
// ── 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;
@@ -592,10 +599,16 @@ interface FrameSession {
// ── JavaScript evaluation ─────────────────────────────────────────────────
evaluate(expression: string): any;
// ── Screenshots & DOM capture ────────────────────────────────────────────
// ── 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;
/** Capture the full page HTML and emit it as a named dom_dump event for debugging */
/** 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 ─────────────────────────────────────────────────
@@ -1439,6 +1452,7 @@ declare var Infinity: number;
controlMode={streamControlMode}
{runLog}
{isRunning}
showLog={true}
on:inject={(e) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const { event, data } = e.detail;
@@ -16,6 +16,9 @@
export let runLog = [];
/** @type {boolean} Whether the script is currently running */
export let isRunning = false;
/** @type {boolean} Show the script log overlay. Only the editor test runner
* feeds runLog; the live campaign view has no script log to show. */
export let showLog = false;
let canvas;
let ws = null;
@@ -473,22 +476,24 @@
{#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>
{#if showLog}
<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>
{/if}
</div>
<!-- Tab bar (only shown when multiple tabs exist) -->
{#if tabs.length > 1}
@@ -607,7 +612,7 @@
</div>
{/if}
{#if logPanelOpen}
{#if showLog && 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%;"