-
Notifications
You must be signed in to change notification settings - Fork 10
feat: Web-based remote desktop streaming via noVNC #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1ae8d2c
b239798
99b84eb
9330bae
219a66a
6c96390
46dffd9
bf0ba04
b31a431
31c64c6
45035d6
255605a
1b186df
10eecd6
96e5722
09eb37c
71853bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
||
| ### Linux Setup | ||
|
Comment on lines
+66
to
+75
|
||
|
|
||
| ```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`: | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,8 +3,10 @@ package api | |||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||
| "context" | ||||||||||||||||||||||||||||||||||||||
| "encoding/json" | ||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||
| "io" | ||||||||||||||||||||||||||||||||||||||
| "log/slog" | ||||||||||||||||||||||||||||||||||||||
| "net" | ||||||||||||||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||||||||||||||
| "os" | ||||||||||||||||||||||||||||||||||||||
| "os/exec" | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 { | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
| 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
AI
Mar 23, 2026
There was a problem hiding this comment.
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
AI
Mar 23, 2026
There was a problem hiding this comment.
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
AI
Mar 24, 2026
There was a problem hiding this comment.
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
AI
Mar 24, 2026
There was a problem hiding this comment.
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
AI
Mar 27, 2026
There was a problem hiding this comment.
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).
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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. | ||||||
|
|
@@ -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). | ||||||
|
|
@@ -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) | ||||||
|
||||||
| r.Get("/api/sessions/{session}/windows/{index}/desktop-info", s.handleDesktopInfo) |
Copilot
AI
Mar 24, 2026
There was a problem hiding this comment.
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.
| r.Get("/api/sessions/{session}/windows/{index}/desktop-info", s.handleDesktopInfo) |
There was a problem hiding this comment.
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"startsrk-virtual-display(CGVirtualDisplay + ScreenCaptureKit + libvncserver) instead of Xvfb/x11vnc. Please update the README to reflect the actual macOS dependency chain (how to build/installrk-virtual-display, required Homebrew deps likelibvncserver, and the needed macOS permissions) or switch the macOS startup script back to the documented XQuartz+x11vnc pipeline.