Skip to content

feat: add WebRTC screen relay and standalone screenshot client - #156

Open
rgarcia wants to merge 8 commits into
mainfrom
rgarcia/webrtc-screen-relay
Open

feat: add WebRTC screen relay and standalone screenshot client#156
rgarcia wants to merge 8 commits into
mainfrom
rgarcia/webrtc-screen-relay

Conversation

@rgarcia

@rgarciargarcia commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a WebRTC SFU relay to the API server that connects to Neko internally and re-serves the VP8 video stream to external WebRTC clients via a single WebSocket signaling endpoint at GET /display/webrtc
  • Adds a standalone Go program (cmd/webrtc-screenshot) that connects to this endpoint, decodes every VP8 frame using CGo libvpx at ~23fps, and atomically writes the latest JPEG screenshot to disk
  • Adds a CGo VP8 decoder wrapper (lib/vpxdecoder) around libvpx's decode API

Architecture

External Client ←WebRTC→ API Server ←WebRTC→ Neko ←GStreamer→ X11 Display
(pion/webrtc) (SFU relay) (VP8 encoder)

The API server acts as a WebRTC SFU (Selective Forwarding Unit): it receives VP8 RTP packets from Neko and forwards them to connected external clients via TrackLocalStaticRTP. No re-encoding, no added latency.

Signaling is minimal — two WebSocket messages total (client sends offer, server returns answer). No trickle ICE.

Key design decisions

  • SFU relay over re-encoding: zero added latency, just RTP forwarding
  • CGo libvpx for full VP8 decode (keyframes + inter-frames): ~43ms frame freshness at 23fps vs 600ms with keyframe-only pure Go decoder
  • Atomic disk writes (write to temp file + os.Rename) so filesystem readers never see partial JPEGs
  • Auto-reconnect on both server→Neko and client→server sides
  • Single endpoint (/display/webrtc) wraps all Neko auth/signaling complexity

New files

FilePurpose
server/lib/webrtcscreen/relay.goWebRTC SFU relay — Neko client + signaling handler
server/lib/vpxdecoder/decoder.goCGo libvpx VP8 decoder (~80 lines)
server/cmd/webrtc-screenshot/main.goStandalone client: WebRTC → VP8 decode → JPEG → disk

Config

Env varDefaultDescription
WEBRTC_RELAY_ENABLEDtrueEnable/disable the relay endpoint

Test plan

  • Built container image with relay changes
  • Ran standalone client outside container connecting to ws://host:10001/display/webrtc
  • Verified ~23fps continuous frame decode at 1920x1080
  • Navigated to 5 different sites via Playwright API, verified each screenshot on disk reflected the navigation correctly

Made with Cursor


Note

Medium Risk
Introduces a new externally reachable WebRTC endpoint plus a CGo/libvpx decoder and multiple new Pion WebRTC dependencies, increasing operational and security surface area. Risk centers on correctness of signaling/auth to Neko, resource usage, and stability of the long-lived relay/reconnect loops.

Overview
Adds a WebRTC screen relay to the API server, mounting GET /display/webrtc to accept a minimal WebSocket offer/answer exchange and forward Neko’s VP8 RTP stream to external WebRTC clients (lazy-started on first client, with reconnect and keyframe requests).

Introduces a standalone cmd/webrtc-screenshot tool that connects to the new endpoint, depacketizes VP8 RTP, decodes frames via a new CGo lib/vpxdecoder wrapper around libvpx, and atomically writes the latest JPEG screenshot to disk.

Updates go.mod/go.sum with Pion WebRTC/RT(P/CP) dependencies and bumps golang.org/x/sync.

Written by Cursor Bugbot for commit e839551. This will update automatically on new commits. Configure here.

Comment threadserver/lib/webrtcscreen/relay.go Outdated
Comment threadserver/lib/vpxdecoder/decoder.go Outdated
Comment threadserver/lib/webrtcscreen/relay.go
Comment threadserver/lib/webrtcscreen/relay.go
Comment threadserver/lib/webrtcscreen/relay.go Outdated
Comment threadserver/cmd/webrtc-screenshot/main.go Outdated
Comment threadserver/lib/webrtcscreen/relay.go
Comment threadserver/cmd/api/main.go Outdated
Comment threadserver/cmd/webrtc-screenshot/main.go
Comment threadserver/cmd/webrtc-screenshot/main.go
Comment threadserver/lib/webrtcscreen/relay.go
@cursor

This comment has been minimized.

@cursor

This comment has been minimized.

Comment threadserver/lib/webrtcscreen/relay.go Outdated
Comment threadserver/cmd/webrtc-screenshot/main.go
@cursor

This comment has been minimized.

Comment threadserver/lib/webrtcscreen/relay.go Outdated
Comment threadserver/lib/webrtcscreen/relay.go
@cursor

This comment has been minimized.

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is ON. A Cloud Agent has been kicked off to fix the reported issues.

Comment threadserver/lib/webrtcscreen/relay.go
Comment threadserver/lib/webrtcscreen/relay.go Outdated
@cursor

cursorBot commented Feb 25, 2026

Copy link
Copy Markdown

Bugbot Autofix prepared fixes for 2 of the 2 bugs found in the latest run.

  • ✅ Fixed: Race condition: first client gets orphaned ready channel
    • Changed Start() to only replace r.ready when the previous channel was already closed (reconnection case), so the original channel from NewRelay is reused and properly closed when the first track arrives.
  • ✅ Fixed: Relay never sends heartbeats; handler listens for nonexistent event
    • Modified waitForEvent to return the payload, extracted heartbeat_interval from system/init, started a periodic client/heartbeat sender goroutine, and removed the dead system/heartbeat handler that Neko never sends.

Create PR

Or push these changes by commenting:

@cursor push e3392ace5a
Preview (e3392ace5a)
diff --git a/server/lib/webrtcscreen/relay.go b/server/lib/webrtcscreen/relay.go--- a/server/lib/webrtcscreen/relay.go+++ b/server/lib/webrtcscreen/relay.go@@ -94,7 +94,13 @@
// Start in a loop for automatic reconnection.
func (r *Relay) Start(ctx context.Context) error {
r.mu.Lock()
-	r.ready = make(chan struct{})+	select {+	case <-r.ready:+ // Previous connection closed the channel; create a fresh one.+ r.ready = make(chan struct{})+	default:+ // Channel is still open (first call), keep it.+	}
r.mu.Unlock()
token, err := r.nekoLogin(ctx)
@@ -124,10 +130,35 @@
r.mu.Unlock()
}()
-	if err := r.waitForEvent(ctx, ws, "system/init"); err != nil {+	initPayload, err := r.waitForEvent(ctx, ws, "system/init")+	if err != nil {
return fmt.Errorf("waiting for system/init: %w", err)
}
+	var initData struct {+ HeartbeatInterval float64 `json:"heartbeat_interval"`+	}+	if initPayload != nil {+ _ = json.Unmarshal(initPayload, &initData)+	}++	if initData.HeartbeatInterval > 0 {+ go func() {+ ticker := time.NewTicker(time.Duration(initData.HeartbeatInterval * float64(time.Second)))+ defer ticker.Stop()+ for {+ select {+ case <-ctx.Done():+ return+ case <-ticker.C:+ if err := sendWSMsg(ctx, ws, "client/heartbeat", nil); err != nil {+ return+ }+ }+ }+ }()+	}+
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
if err != nil {
return fmt.Errorf("creating neko peer connection: %w", err)
@@ -502,15 +533,15 @@
return ws.Write(ctx, cws.MessageText, data)
}
-func (r *Relay) waitForEvent(ctx context.Context, ws *cws.Conn, event string) error {+func (r *Relay) waitForEvent(ctx context.Context, ws *cws.Conn, event string) (json.RawMessage, error) {
for {
_, data, err := ws.Read(ctx)
if err != nil {
- return err+ return nil, err
}
var msg nekoMsg
if json.Unmarshal(data, &msg) == nil && msg.Event == event {
- return nil+ return msg.Payload, nil
}
}
}
@@ -545,8 +576,6 @@
continue
}
switch msg.Event {
- case "system/heartbeat":- _ = sendWSMsg(ctx, ws, "client/heartbeat", nil)
case "signal/candidate":
var candidate webrtc.ICECandidateInit
if json.Unmarshal(msg.Payload, &candidate) == nil {

rgarciaand others added 8 commits March 11, 2026 12:44
Add a WebRTC SFU relay to the API server that connects to Neko internally
and re-serves the VP8 video stream to external WebRTC clients via a single
WebSocket signaling endpoint at /display/webrtc.
Also add a standalone Go program (cmd/webrtc-screenshot) that connects to
this endpoint, decodes every VP8 frame (keyframes + inter-frames) using
CGo libvpx, and atomically writes the latest JPEG to disk. This enables
AI agents and CLI tools to always have a fresh screenshot available by
simply reading a file.
Key design decisions:
- SFU relay (RTP forwarding) instead of re-encoding: zero added latency
- CGo libvpx for full VP8 decode: ~43ms frame freshness at 23fps
- Atomic disk writes (tmp + rename) so readers never see partial files
- Two-message WebSocket signaling (offer/answer), no trickle ICE
- Auto-reconnect on both server and client side
Co-authored-by: Cursor <cursoragent@cursor.com>
- WriteRTP errors now log-and-continue instead of killing all
forwarding permanently (high severity zombie relay bug)
- Reset ready channel on each Start() so reconnections correctly
reflect relay readiness
- Return error on video track timeout instead of blocking forever
- Separate JSON unmarshal errors from type-mismatch errors to avoid
wrapping nil
- Use max(uStride, vStride) for CStride in vpxdecoder to handle
differing U/V plane strides correctly
Co-authored-by: Cursor <cursoragent@cursor.com>
- Remove unused rtp import and var _ *rtp.Packet dummy in relay.go
- Remove unused serveHTTP method, lastWrite field, and net/http import
in webrtc-screenshot client
- Wrap forwardRTP + ready-signal in sync.Once to prevent concurrent
forwarding goroutines from multiple OnTrack callbacks
- Add defer relay.Close() in API server's reconnection goroutine for
proper cleanup on shutdown
- Validate --quality flag is between 1-100 before starting
Co-authored-by: Cursor <cursoragent@cursor.com>
- Register OnConnectionStateChange callback before SetRemoteDescription
in HandleWebSocket to eliminate race where a terminal state transition
could be missed during signaling
- Reset fb.frames to 0 at the start of each decodeLoop so FPS metrics
are accurate after reconnections
Co-authored-by: Cursor <cursoragent@cursor.com>
The relay no longer connects to Neko eagerly on server startup.
Instead, the Neko WebRTC connection is established on-demand when
the first client hits /display/webrtc, avoiding the cost of an
idle WebRTC session when no one is consuming the screen stream.
- Move reconnect loop into Relay.ensureRunning() behind sync.Once
- HandleWebSocket triggers ensureRunning() then waits up to 15s
for the relay to become ready
- Simplify main.go: no background goroutine, just register endpoint
Co-authored-by: Cursor <cursoragent@cursor.com>
- Remove PeerConnectionStateDisconnected from terminal state check in
HandleWebSocket — it's a transient ICE state that can self-recover
- Spawn forwardRTP as a goroutine inside sync.Once.Do so the OnTrack
callback returns immediately instead of blocking forever
Co-authored-by: Cursor <cursoragent@cursor.com>
The relay is lazy-started on first request so there's no cost to
always registering the /display/webrtc endpoint. No need for a
config toggle.
Made-with: Cursor
- Don't replace r.ready on first Start() call — only create a fresh
channel on reconnection (when the previous one was already closed).
Fixes race where first client would wait on an orphaned channel.
- Parse heartbeat_interval from system/init and start a periodic
client/heartbeat sender so Neko doesn't disconnect the relay.
- Remove dead system/heartbeat handler (Neko heartbeats are
client-initiated, not server-pushed).
Made-with: Cursor
@rgarcia
rgarciaforce-pushed the rgarcia/webrtc-screen-relay branch from 7effa88 to e839551CompareMarch 11, 2026 16:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@rgarcia