Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ tmp
.uploads/
test-results/
app/backend/rk
app/backend/rk-virtual-display
tools/rk-virtual-display/rk-virtual-display

# Go embed requires build/frontend/ to exist for compilation; track the .gitkeep
app/backend/build/frontend/*
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,51 @@ To access rk over HTTPS (e.g., from other machines on your tailnet), see:

- [Tailscale guide](docs/wiki/tailscale.md) — zero-config with Tailscale Serve (recommended)

## Desktop Streaming

run-kit can stream graphical desktops to the browser alongside terminal windows.

### macOS Setup

```bash
brew install --cask xquartz
brew install x11vnc
```

**Log out and back in** (or reboot) after installing XQuartz.

Comment on lines +66 to +74

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

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

The macOS setup instructions here recommend installing XQuartz + x11vnc, but the current implementation on runtime.GOOS == "darwin" starts rk-virtual-display (CGVirtualDisplay + ScreenCaptureKit + libvncserver) instead of Xvfb/x11vnc. Please update the README to reflect the actual macOS dependency chain (how to build/install rk-virtual-display, required Homebrew deps like libvncserver, and the needed macOS permissions) or switch the macOS startup script back to the documented XQuartz+x11vnc pipeline.

Copilot uses AI. Check for mistakes.
### Linux Setup
Comment on lines +66 to +75

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

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

The README’s “macOS Setup” section currently instructs installing XQuartz + x11vnc, but the implementation on macOS starts rk-virtual-display (built from tools/rk-virtual-display) instead. Please align the README with the actual macOS dependency/install flow (including how rk-virtual-display gets built/installed) to avoid broken setup steps.

Copilot uses AI. Check for mistakes.

```bash
sudo apt install xvfb x11vnc
```

**Desktop environment** (optional, install one):

```bash
# KDE Plasma (recommended — full desktop)
sudo apt install kde-plasma-desktop

# GNOME
sudo apt install gnome-session

# Xfce (lightweight)
sudo apt install xfce4

# Openbox (minimal — just window management)
sudo apt install openbox

# Or skip — bare X11 with no window manager
```

### Usage

Create a desktop from the command palette (`Cmd+K` → "New Desktop Window"), the window breadcrumb dropdown (`+ New Desktop`), or the dashboard.

Each desktop is a tmux window named `desktop:{label}`. The `desktop:` prefix identifies it as a desktop — rename freely, just keep the prefix.

See [docs/desktop-streaming.md](docs/desktop-streaming.md) for the full architecture, DE support details, isolation model, and troubleshooting.

## Self-Improvement Loop

rk runs as a daemon in a dedicated tmux session. Lifecycle is managed via CLI flags on `rk serve`:
Expand Down
135 changes: 124 additions & 11 deletions app/backend/api/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package api
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
Expand Down Expand Up @@ -65,24 +67,47 @@ func (s *Server) handleRelay(w http.ResponseWriter, r *http.Request) {
return
}

conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
slog.Error("websocket upgrade failed", "err", err)
return
}
defer conn.Close()

// Determine which tmux server this session lives on
server := serverFromRequest(r)

// Verify the session exists and select the target window
// Detect window type BEFORE WebSocket upgrade so desktop can use hijack
windows, err := s.tmux.ListWindows(r.Context(), session, server)
if err != nil || windows == nil {

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

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

Relay now returns an HTTP 404 before upgrading to WebSocket when the session is missing. TerminalClient’s reconnect/redirect logic relies on receiving WS close code 4004; an HTTP handshake failure won’t surface that code to the client. Consider upgrading first and then sending a WebSocket close frame with 4004 (as before) for not-found cases.

Suggested change
if err != nil || windows == nil {
if err != nil || windows == nil {
// For WebSocket clients, complete the upgrade and then send a WS close frame
// with code 4004 so TerminalClient can handle reconnect/redirect logic.
if websocket.IsWebSocketUpgrade(r) {
conn, uerr := upgrader.Upgrade(w, r, nil)
if uerr != nil {
return
}
defer conn.Close()
closeMsg := websocket.FormatCloseMessage(4004, "Session not found")
// Best-effort send of the close control frame with a short deadline.
_ = conn.WriteControl(websocket.CloseMessage, closeMsg, time.Now().Add(time.Second))
return
}
// For non-WebSocket requests, preserve the existing HTTP 404 behavior.

Copilot uses AI. Check for mistakes.
slog.Warn("session not found", "session", session)
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(4004, "Session not found"))
http.Error(w, "Session not found", http.StatusNotFound)
return
}
Comment on lines +73 to 78

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

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

The session-not-found path now returns an HTTP 404 before the WebSocket upgrade. TerminalClient relies on receiving WS close code 4004 to stop reconnecting and redirect; with a handshake 404 it will likely loop reconnects instead. Consider upgrading first and sending a WebSocket close (e.g., 4004) for not-found cases (both terminal and desktop).

Copilot uses AI. Check for mistakes.
var windowType string
windowFound := false
for _, win := range windows {
if win.Index == winIdx {
windowType = win.Type
windowFound = true
break
}
}
if !windowFound {
http.Error(w, "Window not found", http.StatusNotFound)
return
Comment on lines +73 to +90

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

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

TerminalClient relies on the relay WebSocket closing with code 4004 to detect a missing session/window and navigate away. After this change, session/window validation happens before upgrading and returns 404 via http.Error, which causes the browser WebSocket to fail with code 1006 and the client to reconnect indefinitely. Consider always upgrading first (for the terminal path) and then sending a WebSocket close frame with 4004 for "session/window not found" to preserve existing client behavior.

Copilot uses AI. Check for mistakes.
}
Comment on lines +73 to +91

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

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

For desktop windows, session/window-not-found errors currently return plain HTTP 404s before upgrading to WebSocket, while terminal windows use WebSocket close codes (e.g., 4004). For consistency (and so clients can reliably detect not-found), consider upgrading (with the desktop upgrader) and closing with the same close codes/messages as the terminal path.

Copilot uses AI. Check for mistakes.

// Desktop: WebSocket-to-TCP proxy (browser WS ↔ x11vnc raw VNC)
if windowType == "desktop" {
if err := s.tmux.SelectWindow(session, winIdx, server); err != nil {
slog.Error("select-window failed", "err", err)
}
s.handleDesktopRelay(w, r, session, winIdx, server)
return
}

// Terminal: proceed with Gorilla WebSocket upgrade
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
slog.Error("websocket upgrade failed", "err", err)
return
}
defer conn.Close()

// Terminal relay (existing behavior)
if err := s.tmux.SelectWindow(session, winIdx, server); err != nil {
slog.Error("select-window failed", "err", err, "session", session, "window", windowIndex)
conn.WriteMessage(websocket.CloseMessage,
Expand Down Expand Up @@ -190,3 +215,91 @@ func (s *Server) handleRelay(w http.ResponseWriter, r *http.Request) {
}
}
}

// desktopUpgrader negotiates the 'binary' subprotocol that noVNC/websockify use.
var desktopUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
Subprotocols: []string{"binary"},
}

// handleDesktopRelay proxies between a browser WebSocket and x11vnc's raw TCP VNC port.
func (s *Server) handleDesktopRelay(w http.ResponseWriter, r *http.Request, session string, windowIndex int, server string) {
portStr, err := s.tmux.GetWindowOption(session, windowIndex, "@rk_vnc_port", server)
if err != nil {
slog.Warn("VNC port not found", "session", session, "window", windowIndex, "err", err)
http.Error(w, "VNC port not found", http.StatusBadGateway)
return
}
port, err := strconv.Atoi(portStr)
if err != nil {
http.Error(w, "Invalid VNC port", http.StatusBadGateway)
return
}

vncAddr := fmt.Sprintf("127.0.0.1:%d", port)
vncConn, err := net.DialTimeout("tcp", vncAddr, 10*time.Second)
if err != nil {
slog.Error("failed to connect to VNC server", "addr", vncAddr, "err", err)
http.Error(w, "VNC connection failed", http.StatusBadGateway)
return
}
Comment on lines +225 to +245

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

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

PR description/spec mention a WebSocket-to-WebSocket proxy to x11vnc, but handleDesktopRelay actually implements a WebSocket-to-TCP proxy (net.DialTimeout to 127.0.0.1:port). Please align the implementation and docs/PR description (either update docs/spec to reflect WS↔TCP, or switch to dialing x11vnc's WebSocket mode and proxy WS↔WS).

Copilot uses AI. Check for mistakes.

conn, err := desktopUpgrader.Upgrade(w, r, nil)
if err != nil {
vncConn.Close()
slog.Error("desktop websocket upgrade failed", "err", err)
return
}

slog.Info("desktop relay connected", "session", session, "window", windowIndex, "vncAddr", vncAddr, "subprotocol", conn.Subprotocol())

conn.SetReadDeadline(time.Time{})
conn.SetPongHandler(func(string) error { return nil })

var once sync.Once
cleanup := func() {
once.Do(func() {
conn.Close()
vncConn.Close()
})
}
defer cleanup()

// Keepalive pings every 10s
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil {
cleanup()
return
}
}
}()

// Browser WebSocket → VNC TCP
go func() {
defer cleanup()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
if _, err := vncConn.Write(msg); err != nil {
return
}
}
}()

// VNC TCP → Browser WebSocket
buf := make([]byte, 32*1024)
for {
n, err := vncConn.Read(buf)
if err != nil {
return
}
if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
return
Comment on lines +268 to +302

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

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

handleDesktopRelay writes to the same *websocket.Conn from multiple goroutines (the keepalive ping goroutine via WriteControl and the VNC->WS loop via WriteMessage). Gorilla WebSocket connections are not safe for concurrent writers and this can panic/corrupt frames. Use a single writer goroutine (e.g., channel) or a sync.Mutex to serialize all writes (including pings).

Copilot uses AI. Check for mistakes.
}
}
}
10 changes: 10 additions & 0 deletions app/backend/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ type TmuxOps interface {
ListServers(ctx context.Context) ([]string, error)
KillServer(server string) error
ListKeys(server string) ([]string, error)
GetWindowOption(session string, windowIndex int, key, server string) (string, error)
SetWindowOption(session string, windowIndex int, key, value, server string) error
}

// Server holds handler dependencies.
Expand Down Expand Up @@ -126,6 +128,12 @@ func (p *prodTmuxOps) KillServer(server string) error {
func (p *prodTmuxOps) ListKeys(server string) ([]string, error) {
return tmux.ListKeys(server)
}
func (p *prodTmuxOps) GetWindowOption(session string, windowIndex int, key, server string) (string, error) {
return tmux.GetWindowOption(session, windowIndex, key, server)
}
func (p *prodTmuxOps) SetWindowOption(session string, windowIndex int, key, value, server string) error {
return tmux.SetWindowOption(session, windowIndex, key, value, server)
}

// NewRouter creates the chi router with all middleware and routes.
// Uses production dependencies (live tmux, real session fetcher).
Expand Down Expand Up @@ -178,6 +186,8 @@ func (s *Server) buildRouter() chi.Router {
r.Post("/api/sessions/{session}/windows/{index}/select", s.handleWindowSelect)
r.Post("/api/sessions/{session}/windows/{index}/split", s.handleWindowSplit)
r.Post("/api/sessions/{session}/windows/{index}/close-pane", s.handleClosePaneKill)
r.Post("/api/sessions/{session}/windows/{index}/resolution", s.handleWindowResolution)
r.Get("/api/sessions/{session}/windows/{index}/desktop-info", s.handleDesktopInfo)

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

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

This router registers /api/sessions/{session}/windows/{index}/desktop-info, but the handler currently depends on @rk_ws_port which isn't set anywhere, so the endpoint appears non-functional/dead code. If it's not needed for the noVNC flow, consider removing the route to avoid maintaining unused API surface.

Suggested change
r.Get("/api/sessions/{session}/windows/{index}/desktop-info", s.handleDesktopInfo)

Copilot uses AI. Check for mistakes.

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

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

This route registers /desktop-info, but the corresponding handler reads @rk_ws_port which isn’t set anywhere and nothing calls this endpoint. Removing it would reduce API surface and avoid confusion.

Suggested change
r.Get("/api/sessions/{session}/windows/{index}/desktop-info", s.handleDesktopInfo)

Copilot uses AI. Check for mistakes.
r.Get("/api/directories", s.handleDirectories)
r.Post("/api/sessions/{session}/upload", s.handleUpload)
r.Get("/api/sessions/stream", s.handleSSE)
Expand Down
32 changes: 32 additions & 0 deletions app/backend/api/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -65,6 +66,11 @@ type mockTmuxOps struct {
killActivePaneSession string
killActivePaneIndex int

getWindowOptionResult string
setWindowOptionCalled bool
setWindowOptionKey string
setWindowOptionValue string

err error
}

Expand All @@ -90,6 +96,20 @@ func (m *mockTmuxOps) CreateWindow(session, name, cwd, server string) error {
m.createWindowSession = session
m.createWindowName = name
m.createWindowCwd = cwd
// Append the created window to listWindowsResult so subsequent ListWindows
// calls can find it (needed for desktop window creation flow).
if m.err == nil {
nextIndex := len(m.listWindowsResult)
winType := "terminal"
if len(name) > 8 && name[:8] == "desktop:" {
winType = "desktop"
}
m.listWindowsResult = append(m.listWindowsResult, tmux.WindowInfo{
Index: nextIndex,
Name: name,
Type: winType,
})
}
return m.err
}
func (m *mockTmuxOps) KillWindow(session string, index int, server string) error {
Expand Down Expand Up @@ -143,6 +163,18 @@ func (m *mockTmuxOps) KillServer(server string) error {
func (m *mockTmuxOps) ListKeys(server string) ([]string, error) {
return nil, nil
}
func (m *mockTmuxOps) GetWindowOption(session string, windowIndex int, key, server string) (string, error) {
if m.getWindowOptionResult != "" {
return m.getWindowOptionResult, nil
}
return "", fmt.Errorf("option not set")
}
func (m *mockTmuxOps) SetWindowOption(session string, windowIndex int, key, value, server string) error {
m.setWindowOptionCalled = true
m.setWindowOptionKey = key
m.setWindowOptionValue = value
return m.err
}

func newTestRouter(sf SessionFetcher, ops TmuxOps) http.Handler {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
Expand Down
Loading
Loading