From 1ae8d2c32efd507b3e03358baea54576acfbab14 Mon Sep 17 00:00:00 2001 From: Vivek S Date: Mon, 23 Mar 2026 08:14:36 +0000 Subject: [PATCH 01/17] feat: add web-based remote desktop streaming via noVNC Integrate desktop windows into run-kit's session model using Xvfb + x11vnc + noVNC. Desktop windows are tmux-managed VNC servers that stream through the existing WebSocket relay. Backend: unified window creation with type parameter, VNC WebSocket proxy in relay handler, dynamic port/display allocation, resolution validation and change endpoint. Frontend: DesktopClient component with noVNC scaleViewport, desktop bottom bar (clipboard, resolution, fullscreen), creation from command palette/breadcrumb/dashboard. --- app/backend/api/relay.go | 95 +++++- app/backend/api/router.go | 9 + app/backend/api/sessions_test.go | 32 ++ app/backend/api/windows.go | 187 ++++++++++- app/backend/api/windows_test.go | 132 ++++++++ app/backend/internal/tmux/tmux.go | 34 ++ app/backend/internal/tmux/tmux_test.go | 52 ++- app/backend/internal/validate/validate.go | 15 + .../internal/validate/validate_test.go | 39 +++ app/frontend/package.json | 1 + app/frontend/pnpm-lock.yaml | 8 + app/frontend/src/api/client.ts | 36 ++ app/frontend/src/app.tsx | 99 ++++-- .../src/components/breadcrumb-dropdown.tsx | 45 +-- .../src/components/dashboard.test.tsx | 3 + app/frontend/src/components/dashboard.tsx | 38 ++- .../src/components/desktop-bottom-bar.tsx | 163 +++++++++ .../src/components/desktop-client.test.tsx | 101 ++++++ .../src/components/desktop-client.tsx | 104 ++++++ app/frontend/src/components/sidebar.test.tsx | 3 + app/frontend/src/components/top-bar.test.tsx | 3 + app/frontend/src/components/top-bar.tsx | 7 +- app/frontend/src/types.ts | 1 + app/frontend/src/types/novnc.d.ts | 57 ++++ app/frontend/tests/msw/handlers.ts | 11 +- docs/memory/run-kit/architecture.md | 58 +++- docs/memory/run-kit/tmux-sessions.md | 34 ++ docs/memory/run-kit/ui-patterns.md | 67 +++- .../.history.jsonl | 34 ++ .../.status.yaml | 42 +++ .../checklist.md | 67 ++++ .../intake.md | 199 +++++++++++ .../spec.md | 308 ++++++++++++++++++ .../tasks.md | 70 ++++ 34 files changed, 2079 insertions(+), 75 deletions(-) create mode 100644 app/frontend/src/components/desktop-bottom-bar.tsx create mode 100644 app/frontend/src/components/desktop-client.test.tsx create mode 100644 app/frontend/src/components/desktop-client.tsx create mode 100644 app/frontend/src/types/novnc.d.ts create mode 100644 fab/changes/260323-a805-web-based-remote-desktop/.history.jsonl create mode 100644 fab/changes/260323-a805-web-based-remote-desktop/.status.yaml create mode 100644 fab/changes/260323-a805-web-based-remote-desktop/checklist.md create mode 100644 fab/changes/260323-a805-web-based-remote-desktop/intake.md create mode 100644 fab/changes/260323-a805-web-based-remote-desktop/spec.md create mode 100644 fab/changes/260323-a805-web-based-remote-desktop/tasks.md diff --git a/app/backend/api/relay.go b/app/backend/api/relay.go index 16a7c9c1..11dbfeb6 100644 --- a/app/backend/api/relay.go +++ b/app/backend/api/relay.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "fmt" "io" "log/slog" "net/http" @@ -75,7 +76,7 @@ func (s *Server) handleRelay(w http.ResponseWriter, r *http.Request) { // Determine which tmux server this session lives on server := serverFromRequest(r) - // Verify the session exists and select the target window + // Verify the session exists and detect window type windows, err := s.tmux.ListWindows(r.Context(), session, server) if err != nil || windows == nil { slog.Warn("session not found", "session", session) @@ -83,6 +84,30 @@ func (s *Server) handleRelay(w http.ResponseWriter, r *http.Request) { websocket.FormatCloseMessage(4004, "Session not found")) return } + + // Find the target window and check its type + var windowType string + windowFound := false + for _, win := range windows { + if win.Index == winIdx { + windowType = win.Type + windowFound = true + break + } + } + if !windowFound { + conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(4004, "Window not found")) + return + } + + // Branch: desktop VNC proxy vs terminal PTY relay + if windowType == "desktop" { + s.handleDesktopRelay(conn, session, winIdx, server) + return + } + + // 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, @@ -190,3 +215,71 @@ func (s *Server) handleRelay(w http.ResponseWriter, r *http.Request) { } } } + +// handleDesktopRelay proxies a WebSocket connection to the x11vnc VNC WebSocket. +func (s *Server) handleDesktopRelay(conn *websocket.Conn, session string, windowIndex int, server string) { + // Read @rk_vnc_port from the tmux window option + portStr, err := s.tmux.GetWindowOption(session, windowIndex, "@rk_vnc_port", server) + if err != nil { + slog.Warn("VNC port not found for desktop window", "session", session, "window", windowIndex, "err", err) + conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(4002, "VNC port not found")) + return + } + + port, err := strconv.Atoi(portStr) + if err != nil { + slog.Error("invalid VNC port value", "port", portStr, "err", err) + conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(4002, "Invalid VNC port")) + return + } + + // Dial the VNC WebSocket on localhost only (security: no external connections) + vncURL := fmt.Sprintf("ws://localhost:%d", port) + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + } + vncConn, _, err := dialer.Dial(vncURL, nil) + if err != nil { + slog.Error("failed to connect to VNC WebSocket", "url", vncURL, "err", err) + conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(4003, "VNC connection failed")) + return + } + + var once sync.Once + cleanup := func() { + once.Do(func() { + conn.Close() + vncConn.Close() + slog.Debug("desktop relay cleanup", "session", session, "window", windowIndex) + }) + } + defer cleanup() + + // Bidirectional copy: browser -> VNC + go func() { + defer cleanup() + for { + msgType, msg, err := conn.ReadMessage() + if err != nil { + return + } + if err := vncConn.WriteMessage(msgType, msg); err != nil { + return + } + } + }() + + // Bidirectional copy: VNC -> browser + for { + msgType, msg, err := vncConn.ReadMessage() + if err != nil { + return + } + if err := conn.WriteMessage(msgType, msg); err != nil { + return + } + } +} diff --git a/app/backend/api/router.go b/app/backend/api/router.go index 2c149191..9a578e59 100644 --- a/app/backend/api/router.go +++ b/app/backend/api/router.go @@ -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,7 @@ 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/directories", s.handleDirectories) r.Post("/api/sessions/{session}/upload", s.handleUpload) r.Get("/api/sessions/stream", s.handleSSE) diff --git a/app/backend/api/sessions_test.go b/app/backend/api/sessions_test.go index 5cddfe72..6e1eb725 100644 --- a/app/backend/api/sessions_test.go +++ b/app/backend/api/sessions_test.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "fmt" "log/slog" "net/http" "net/http/httptest" @@ -65,6 +66,11 @@ type mockTmuxOps struct { killActivePaneSession string killActivePaneIndex int + getWindowOptionResult string + setWindowOptionCalled bool + setWindowOptionKey string + setWindowOptionValue string + err error } @@ -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 { @@ -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)) diff --git a/app/backend/api/windows.go b/app/backend/api/windows.go index 117735fd..1d040ad3 100644 --- a/app/backend/api/windows.go +++ b/app/backend/api/windows.go @@ -3,6 +3,9 @@ package api import ( "context" "encoding/json" + "fmt" + "log/slog" + "net" "net/http" "strconv" "strings" @@ -21,8 +24,10 @@ func (s *Server) handleWindowCreate(w http.ResponseWriter, r *http.Request) { } var body struct { - Name string `json:"name"` - CWD string `json:"cwd"` + Name string `json:"name"` + CWD string `json:"cwd"` + Type string `json:"type"` + Resolution string `json:"resolution"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON body") @@ -36,6 +41,77 @@ func (s *Server) handleWindowCreate(w http.ResponseWriter, r *http.Request) { server := serverFromRequest(r) + // Desktop window creation + if body.Type == "desktop" { + resolution := body.Resolution + if resolution == "" { + resolution = "1920x1080" + } + if errMsg := validate.ValidateResolution(resolution); errMsg != "" { + writeError(w, http.StatusBadRequest, errMsg) + return + } + + // Allocate a free port via net.Listen + port, err := allocateFreePort() + if err != nil { + writeError(w, http.StatusInternalServerError, "Failed to allocate VNC port") + return + } + + // Derive display number from port + displayNum := port - 5900 + if displayNum < 0 { + displayNum = port % 1000 + } + + // Create tmux window with desktop: prefix + windowName := "desktop:" + body.Name + var resolvedCwd string + if windows, listErr := s.tmux.ListWindows(session, server); listErr == nil && len(windows) > 0 { + resolvedCwd = windows[0].WorktreePath + } + if err := s.tmux.CreateWindow(session, windowName, resolvedCwd, server); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Find the newly created window index + windows, err := s.tmux.ListWindows(session, server) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + windowIndex := -1 + for _, win := range windows { + if win.Name == windowName { + windowIndex = win.Index + } + } + if windowIndex < 0 { + writeError(w, http.StatusInternalServerError, "Failed to find created desktop window") + return + } + + // Store VNC port as tmux window option via internal/tmux (not in shell script) + if err := s.tmux.SetWindowOption(session, windowIndex, "@rk_vnc_port", strconv.Itoa(port), server); err != nil { + slog.Error("failed to set VNC port window option", "err", err) + // Non-fatal — relay will fail to connect but desktop still works + } + + // Generate and send startup script + script := desktopStartupScript(displayNum, port, resolution) + if err := s.tmux.SendKeys(session, windowIndex, script, server); err != nil { + slog.Error("failed to send desktop startup script", "err", err) + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, map[string]bool{"ok": true}) + return + } + + // Terminal window creation (existing behavior) var resolvedCwd string if body.CWD != "" { if errMsg := validate.ValidatePath(body.CWD, "Working directory"); errMsg != "" { @@ -69,6 +145,45 @@ func (s *Server) handleWindowCreate(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, map[string]bool{"ok": true}) } +// allocateFreePort finds a free TCP port using the net.Listen trick. +func allocateFreePort() (int, error) { + l, err := net.Listen("tcp", ":0") + if err != nil { + return 0, err + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return port, nil +} + +// desktopStartupScript generates the shell script to launch Xvfb, detect WM, and start x11vnc. +// The VNC port is stored as a tmux window option by the caller (via SetWindowOption), +// not inside this script, to keep all tmux interaction through internal/tmux. +func desktopStartupScript(displayNum, port int, resolution string) string { + return fmt.Sprintf(`export DISPLAY=:%d && `+ + `Xvfb :%d -screen 0 %sx24 &>/dev/null & `+ + `sleep 1 && `+ + `WM=""; `+ + `if command -v x-session-manager &>/dev/null; then WM=x-session-manager; `+ + `elif [ -n "$XDG_CURRENT_DESKTOP" ]; then `+ + `case "$XDG_CURRENT_DESKTOP" in `+ + `GNOME) command -v mutter &>/dev/null && WM=mutter;; `+ + `KDE) command -v kwin &>/dev/null && WM=kwin;; `+ + `XFCE) command -v xfwm4 &>/dev/null && WM=xfwm4;; `+ + `esac; `+ + `fi; `+ + `if [ -z "$WM" ]; then `+ + `for wm in openbox fluxbox i3 xfwm4 mutter kwin; do `+ + `if command -v "$wm" &>/dev/null; then WM="$wm"; break; fi; `+ + `done; `+ + `fi; `+ + `[ -n "$WM" ] && $WM &>/dev/null & `+ + `exec x11vnc -display :%d -rfbport %d -nopw -forever -shared -noxdamage -ws`, + displayNum, displayNum, resolution, + displayNum, port, + ) +} + // parseWindowIndex extracts and validates the window index from the URL. func parseWindowIndex(r *http.Request) (int, bool) { indexStr := chi.URLParam(r, "index") @@ -239,3 +354,71 @@ func (s *Server) handleWindowKeys(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } + +// handleWindowResolution changes the desktop resolution by restarting Xvfb + x11vnc. +func (s *Server) handleWindowResolution(w http.ResponseWriter, r *http.Request) { + session := chi.URLParam(r, "session") + if errMsg := validate.ValidateName(session, "Session name"); errMsg != "" { + writeError(w, http.StatusBadRequest, errMsg) + return + } + + index, ok := parseWindowIndex(r) + if !ok { + writeError(w, http.StatusBadRequest, "Invalid window index") + return + } + + var body struct { + Resolution string `json:"resolution"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON body") + return + } + + if errMsg := validate.ValidateResolution(body.Resolution); errMsg != "" { + writeError(w, http.StatusBadRequest, errMsg) + return + } + + server := serverFromRequest(r) + + // Read existing VNC port from window option + portStr, err := s.tmux.GetWindowOption(session, index, "@rk_vnc_port", server) + if err != nil { + writeError(w, http.StatusBadRequest, "Window is not a desktop window or VNC port not set") + return + } + port, err := strconv.Atoi(portStr) + if err != nil { + writeError(w, http.StatusInternalServerError, "Invalid VNC port value") + return + } + + // Derive display number from port (same logic as creation) + displayNum := port - 5900 + if displayNum < 0 { + displayNum = port % 1000 + } + + // Send restart script: kill existing Xvfb and x11vnc, relaunch at new resolution + script := fmt.Sprintf( + `pkill -f 'Xvfb :%d' 2>/dev/null; pkill -f 'x11vnc.*:%d' 2>/dev/null; sleep 0.5 && `+ + `export DISPLAY=:%d && `+ + `Xvfb :%d -screen 0 %sx24 &>/dev/null & `+ + `sleep 1 && `+ + `exec x11vnc -display :%d -rfbport %d -nopw -forever -shared -noxdamage -ws`, + displayNum, displayNum, + displayNum, + displayNum, body.Resolution, + displayNum, port, + ) + + if err := s.tmux.SendKeys(session, index, script, server); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/app/backend/api/windows_test.go b/app/backend/api/windows_test.go index 14c7ad59..440fb026 100644 --- a/app/backend/api/windows_test.go +++ b/app/backend/api/windows_test.go @@ -283,6 +283,138 @@ func TestWindowSplitInvalidSession(t *testing.T) { } } +func TestWindowCreateDesktop(t *testing.T) { + ops := &mockTmuxOps{ + listWindowsResult: []tmux.WindowInfo{ + {Index: 0, Name: "zsh", Type: "terminal", WorktreePath: "/home/user"}, + }, + } + router := newTestRouter(&mockSessionFetcher{}, ops) + + body := `{"name":"dev","type":"desktop"}` + req := httptest.NewRequest(http.MethodPost, "/api/sessions/run-kit/windows", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + if !ops.createWindowCalled { + t.Error("CreateWindow was not called") + } + // Desktop window should have desktop: prefix + if ops.createWindowName != "desktop:dev" { + t.Errorf("name = %q, want %q", ops.createWindowName, "desktop:dev") + } + // SendKeys should have been called with the startup script + if !ops.sendKeysCalled { + t.Error("SendKeys was not called (expected startup script)") + } +} + +func TestWindowCreateDesktopDefaultResolution(t *testing.T) { + ops := &mockTmuxOps{ + listWindowsResult: []tmux.WindowInfo{ + {Index: 0, Name: "zsh", Type: "terminal", WorktreePath: "/home/user"}, + {Index: 1, Name: "desktop:dev", Type: "desktop", WorktreePath: "/home/user"}, + }, + } + router := newTestRouter(&mockSessionFetcher{}, ops) + + body := `{"name":"test","type":"desktop"}` + req := httptest.NewRequest(http.MethodPost, "/api/sessions/run-kit/windows", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + // The startup script should contain the default resolution 1920x1080 + if !strings.Contains(ops.sendKeysKeys, "1920x1080") { + t.Errorf("startup script does not contain default resolution 1920x1080: %s", ops.sendKeysKeys) + } +} + +func TestWindowCreateDesktopCustomResolution(t *testing.T) { + ops := &mockTmuxOps{ + listWindowsResult: []tmux.WindowInfo{ + {Index: 0, Name: "zsh", Type: "terminal", WorktreePath: "/home/user"}, + {Index: 1, Name: "desktop:hires", Type: "desktop", WorktreePath: "/home/user"}, + }, + } + router := newTestRouter(&mockSessionFetcher{}, ops) + + body := `{"name":"hires","type":"desktop","resolution":"2560x1440"}` + req := httptest.NewRequest(http.MethodPost, "/api/sessions/run-kit/windows", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + if !strings.Contains(ops.sendKeysKeys, "2560x1440") { + t.Errorf("startup script does not contain custom resolution 2560x1440: %s", ops.sendKeysKeys) + } +} + +func TestWindowCreateDesktopInvalidResolution(t *testing.T) { + router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{}) + + body := `{"name":"bad","type":"desktop","resolution":"foo; rm -rf /"}` + req := httptest.NewRequest(http.MethodPost, "/api/sessions/run-kit/windows", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestWindowResolutionChange(t *testing.T) { + ops := &mockTmuxOps{ + getWindowOptionResult: "59234", + } + router := newTestRouter(&mockSessionFetcher{}, ops) + + body := `{"resolution":"2560x1440"}` + req := httptest.NewRequest(http.MethodPost, "/api/sessions/run-kit/windows/1/resolution", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if !ops.sendKeysCalled { + t.Error("SendKeys was not called (expected restart script)") + } + if !strings.Contains(ops.sendKeysKeys, "2560x1440") { + t.Errorf("restart script does not contain new resolution: %s", ops.sendKeysKeys) + } +} + +func TestWindowResolutionChangeInvalidResolution(t *testing.T) { + router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{}) + + body := `{"resolution":"bad"}` + req := httptest.NewRequest(http.MethodPost, "/api/sessions/run-kit/windows/1/resolution", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + func TestWindowSplitInvalidJSON(t *testing.T) { router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{}) diff --git a/app/backend/internal/tmux/tmux.go b/app/backend/internal/tmux/tmux.go index 254b741d..e665efbe 100644 --- a/app/backend/internal/tmux/tmux.go +++ b/app/backend/internal/tmux/tmux.go @@ -146,6 +146,7 @@ const ( type WindowInfo struct { Index int `json:"index"` Name string `json:"name"` + Type string `json:"type"` WorktreePath string `json:"worktreePath"` Activity string `json:"activity"` // "active" or "idle" IsActiveWindow bool `json:"isActiveWindow"` @@ -272,9 +273,15 @@ func parseWindows(lines []string, nowUnix int64) []WindowInfo { isActive := strings.TrimSpace(parts[4]) == "1" paneCmd := strings.TrimSpace(parts[5]) + winType := "terminal" + if strings.HasPrefix(parts[1], "desktop:") { + winType = "desktop" + } + windows = append(windows, WindowInfo{ Index: index, Name: parts[1], + Type: winType, WorktreePath: parts[2], Activity: activity, IsActiveWindow: isActive, @@ -592,3 +599,30 @@ func KillServer(server string) error { } return err } + +// GetWindowOption reads a tmux user window option (e.g. @rk_vnc_port) from a specific window. +// Returns the value and nil on success, or empty string and error if not set or command fails. +func GetWindowOption(session string, windowIndex int, key string, server string) (string, error) { + ctx, cancel := withTimeout() + defer cancel() + + target := fmt.Sprintf("%s:%d", session, windowIndex) + lines, err := tmuxExecServer(ctx, server, "show-options", "-wv", "-t", target, key) + if err != nil { + return "", err + } + if len(lines) == 0 { + return "", fmt.Errorf("window option %s not set", key) + } + return strings.TrimSpace(lines[0]), nil +} + +// SetWindowOption sets a tmux user window option (e.g. @rk_vnc_port) on a specific window. +func SetWindowOption(session string, windowIndex int, key, value string, server string) error { + ctx, cancel := withTimeout() + defer cancel() + + target := fmt.Sprintf("%s:%d", session, windowIndex) + _, err := tmuxExecServer(ctx, server, "set-option", "-w", "-t", target, key, value) + return err +} diff --git a/app/backend/internal/tmux/tmux_test.go b/app/backend/internal/tmux/tmux_test.go index 5b907981..cdee956c 100644 --- a/app/backend/internal/tmux/tmux_test.go +++ b/app/backend/internal/tmux/tmux_test.go @@ -144,7 +144,7 @@ func TestParseWindows(t *testing.T) { }, now: fakeNow, want: []WindowInfo{ - {Index: 0, Name: "dev", WorktreePath: "/home/user/project", Activity: "active", IsActiveWindow: true, PaneCommand: "claude", ActivityTimestamp: fakeNow - 1}, + {Index: 0, Name: "dev", Type: "terminal", WorktreePath: "/home/user/project", Activity: "active", IsActiveWindow: true, PaneCommand: "claude", ActivityTimestamp: fakeNow - 1}, }, }, { @@ -154,7 +154,7 @@ func TestParseWindows(t *testing.T) { }, now: fakeNow, want: []WindowInfo{ - {Index: 0, Name: "dev", WorktreePath: "/home/user/project", Activity: "idle", IsActiveWindow: false, PaneCommand: "zsh", ActivityTimestamp: fakeNow - ActivityThresholdSeconds - 100}, + {Index: 0, Name: "dev", Type: "terminal", WorktreePath: "/home/user/project", Activity: "idle", IsActiveWindow: false, PaneCommand: "zsh", ActivityTimestamp: fakeNow - ActivityThresholdSeconds - 100}, }, }, { @@ -165,8 +165,8 @@ func TestParseWindows(t *testing.T) { }, now: fakeNow, want: []WindowInfo{ - {Index: 0, Name: "dev", WorktreePath: "/home/user/project", Activity: "active", IsActiveWindow: true, PaneCommand: "claude", ActivityTimestamp: fakeNow}, - {Index: 2, Name: "build", WorktreePath: "/tmp/build", Activity: "active", IsActiveWindow: false, PaneCommand: "make", ActivityTimestamp: fakeNow}, + {Index: 0, Name: "dev", Type: "terminal", WorktreePath: "/home/user/project", Activity: "active", IsActiveWindow: true, PaneCommand: "claude", ActivityTimestamp: fakeNow}, + {Index: 2, Name: "build", Type: "terminal", WorktreePath: "/tmp/build", Activity: "active", IsActiveWindow: false, PaneCommand: "make", ActivityTimestamp: fakeNow}, }, }, { @@ -183,7 +183,7 @@ func TestParseWindows(t *testing.T) { }, now: fakeNow, want: []WindowInfo{ - {Index: 1, Name: "good", WorktreePath: "/home/user", Activity: "active", IsActiveWindow: true, PaneCommand: "zsh", ActivityTimestamp: fakeNow}, + {Index: 1, Name: "good", Type: "terminal", WorktreePath: "/home/user", Activity: "active", IsActiveWindow: true, PaneCommand: "zsh", ActivityTimestamp: fakeNow}, }, }, { @@ -193,7 +193,7 @@ func TestParseWindows(t *testing.T) { }, now: fakeNow, want: []WindowInfo{ - {Index: 0, Name: "edge", WorktreePath: "/path", Activity: "active", IsActiveWindow: false, PaneCommand: "bash", ActivityTimestamp: fakeNow - ActivityThresholdSeconds}, + {Index: 0, Name: "edge", Type: "terminal", WorktreePath: "/path", Activity: "active", IsActiveWindow: false, PaneCommand: "bash", ActivityTimestamp: fakeNow - ActivityThresholdSeconds}, }, }, { @@ -203,7 +203,7 @@ func TestParseWindows(t *testing.T) { }, now: fakeNow, want: []WindowInfo{ - {Index: 0, Name: "past", WorktreePath: "/path", Activity: "idle", IsActiveWindow: false, PaneCommand: "vim", ActivityTimestamp: fakeNow - ActivityThresholdSeconds - 1}, + {Index: 0, Name: "past", Type: "terminal", WorktreePath: "/path", Activity: "idle", IsActiveWindow: false, PaneCommand: "vim", ActivityTimestamp: fakeNow - ActivityThresholdSeconds - 1}, }, }, { @@ -213,7 +213,7 @@ func TestParseWindows(t *testing.T) { }, now: fakeNow, want: []WindowInfo{ - {Index: 0, Name: "work", WorktreePath: "/home/user/code", Activity: "active", IsActiveWindow: true, PaneCommand: "node", ActivityTimestamp: fakeNow}, + {Index: 0, Name: "work", Type: "terminal", WorktreePath: "/home/user/code", Activity: "active", IsActiveWindow: true, PaneCommand: "node", ActivityTimestamp: fakeNow}, }, }, { @@ -223,7 +223,7 @@ func TestParseWindows(t *testing.T) { }, now: 1710300100, want: []WindowInfo{ - {Index: 0, Name: "ts", WorktreePath: "/path", Activity: "idle", IsActiveWindow: false, PaneCommand: "zsh", ActivityTimestamp: 1710300000}, + {Index: 0, Name: "ts", Type: "terminal", WorktreePath: "/path", Activity: "idle", IsActiveWindow: false, PaneCommand: "zsh", ActivityTimestamp: 1710300000}, }, }, } @@ -247,6 +247,9 @@ func TestParseWindows(t *testing.T) { if got[i].Name != tt.want[i].Name { t.Errorf("window[%d].Name = %q, want %q", i, got[i].Name, tt.want[i].Name) } + if got[i].Type != tt.want[i].Type { + t.Errorf("window[%d].Type = %q, want %q", i, got[i].Type, tt.want[i].Type) + } if got[i].WorktreePath != tt.want[i].WorktreePath { t.Errorf("window[%d].WorktreePath = %q, want %q", i, got[i].WorktreePath, tt.want[i].WorktreePath) } @@ -267,6 +270,37 @@ func TestParseWindows(t *testing.T) { } } +func TestParseWindowsDesktopType(t *testing.T) { + const fakeNow int64 = 1700000000 + + tests := []struct { + name string + winName string + wantType string + }{ + {"desktop prefix sets type desktop", "desktop:dev", "desktop"}, + {"desktop prefix with suffix", "desktop:my-env", "desktop"}, + {"plain name sets type terminal", "zsh", "terminal"}, + {"no prefix sets type terminal", "build", "terminal"}, + {"desktop in middle is terminal", "my-desktop-win", "terminal"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lines := []string{ + windowLine(0, tt.winName, "/home/user", fakeNow, 1, "bash"), + } + got := parseWindows(lines, fakeNow) + if len(got) != 1 { + t.Fatalf("expected 1 window, got %d", len(got)) + } + if got[0].Type != tt.wantType { + t.Errorf("Type = %q, want %q", got[0].Type, tt.wantType) + } + }) + } +} + func TestSanitizeEnv(t *testing.T) { tests := []struct { name string diff --git a/app/backend/internal/validate/validate.go b/app/backend/internal/validate/validate.go index 13891a52..114805e4 100644 --- a/app/backend/internal/validate/validate.go +++ b/app/backend/internal/validate/validate.go @@ -104,6 +104,21 @@ func ValidatePath(path, label string) string { return "" } +// resolutionPattern matches valid resolution strings: {width}x{height} with 3-5 digit numbers. +var resolutionPattern = regexp.MustCompile(`^\d{3,5}x\d{3,5}$`) + +// ValidateResolution validates a display resolution string (e.g. "1920x1080"). +// Returns empty string if valid, error message if invalid. +func ValidateResolution(res string) string { + if res == "" { + return "Resolution cannot be empty" + } + if !resolutionPattern.MatchString(res) { + return "Resolution must be in WIDTHxHEIGHT format (e.g. 1920x1080)" + } + return "" +} + // SanitizeFilename sanitizes a user-provided filename for safe disk storage. func SanitizeFilename(name string) string { // Strip null bytes diff --git a/app/backend/internal/validate/validate_test.go b/app/backend/internal/validate/validate_test.go index 633cb695..13f5fb97 100644 --- a/app/backend/internal/validate/validate_test.go +++ b/app/backend/internal/validate/validate_test.go @@ -151,6 +151,45 @@ func TestExpandTilde(t *testing.T) { } } +func TestValidateResolution(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"valid 1920x1080", "1920x1080", false}, + {"valid 800x600", "800x600", false}, + {"valid 2560x1440", "2560x1440", false}, + {"valid 1280x720", "1280x720", false}, + {"valid small 320x240", "320x240", false}, + {"valid 5 digits", "10000x10000", false}, + {"empty string", "", true}, + {"just text", "foo", true}, + {"shell injection", "1920x1080; rm -rf /", true}, + {"missing height", "1920x", true}, + {"missing width", "x1080", true}, + {"too few digits width", "12x1080", true}, + {"too few digits height", "1920x10", true}, + {"too many digits", "123456x1080", true}, + {"float resolution", "1920.5x1080", true}, + {"negative", "-1920x1080", true}, + {"spaces", "1920 x 1080", true}, + {"uppercase X", "1920X1080", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ValidateResolution(tt.input) + if tt.wantErr && result == "" { + t.Error("expected error but got none") + } + if !tt.wantErr && result != "" { + t.Errorf("expected no error but got: %s", result) + } + }) + } +} + func TestSanitizeFilename(t *testing.T) { tests := []struct { name string diff --git a/app/frontend/package.json b/app/frontend/package.json index 7f16a736..3a17e3fe 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -12,6 +12,7 @@ "test:e2e": "playwright test" }, "dependencies": { + "@novnc/novnc": "^1.6.0", "@tanstack/react-router": "^1.114.0", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.10.0", diff --git a/app/frontend/pnpm-lock.yaml b/app/frontend/pnpm-lock.yaml index 03a2de41..98f5108c 100644 --- a/app/frontend/pnpm-lock.yaml +++ b/app/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@novnc/novnc': + specifier: ^1.6.0 + version: 1.6.0 '@tanstack/react-router': specifier: ^1.114.0 version: 1.166.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1117,6 +1120,9 @@ packages: resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} + '@novnc/novnc@1.6.0': + resolution: {integrity: sha512-CJrmdSe9Yt2ZbLsJpVFoVkEu0KICEvnr3njW25Nz0jodaiFJtg8AYLGZogRYy0/N5HUWkGUsCmegKXYBSqwygw==} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -4040,6 +4046,8 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@novnc/novnc@1.6.0': {} + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': diff --git a/app/frontend/src/api/client.ts b/app/frontend/src/api/client.ts index 405b0764..4a8f219b 100644 --- a/app/frontend/src/api/client.ts +++ b/app/frontend/src/api/client.ts @@ -109,6 +109,42 @@ export async function createWindow( return res.json(); } +export async function createDesktopWindow( + session: string, + name?: string, + resolution?: string, +): Promise<{ ok: boolean }> { + const body: Record = { + name: name ?? "desktop", + type: "desktop", + }; + if (resolution) body.resolution = resolution; + const res = await fetch(withServer(`/api/sessions/${encodeURIComponent(session)}/windows`), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) await throwOnError(res); + return res.json(); +} + +export async function changeDesktopResolution( + session: string, + windowIndex: number, + resolution: string, +): Promise<{ ok: boolean }> { + const res = await fetch( + withServer(`/api/sessions/${encodeURIComponent(session)}/windows/${windowIndex}/resolution`), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ resolution }), + }, + ); + if (!res.ok) await throwOnError(res); + return res.json(); +} + export async function killWindow( session: string, index: number, diff --git a/app/frontend/src/app.tsx b/app/frontend/src/app.tsx index d5f4158f..9aab5b6e 100644 --- a/app/frontend/src/app.tsx +++ b/app/frontend/src/app.tsx @@ -8,13 +8,15 @@ import { useDialogState } from "@/hooks/use-dialog-state"; import { TopBar } from "@/components/top-bar"; import { Sidebar } from "@/components/sidebar"; import { TerminalClient } from "@/components/terminal-client"; +import { DesktopClient } from "@/components/desktop-client"; import { BottomBar } from "@/components/bottom-bar"; +import { DesktopBottomBar } from "@/components/desktop-bottom-bar"; import type { PaletteAction } from "@/components/command-palette"; import { Dialog } from "@/components/dialog"; import { Dashboard } from "@/components/dashboard"; import { KeyboardShortcuts } from "@/components/keyboard-shortcuts"; -import { selectWindow, createWindow, splitWindow, closePane, reloadTmuxConfig, initTmuxConf, getHealth, createServer, killServer as killServerApi } from "@/api/client"; +import { selectWindow, createWindow, createDesktopWindow, changeDesktopResolution, splitWindow, closePane, reloadTmuxConfig, initTmuxConf, getHealth, createServer, killServer as killServerApi } from "@/api/client"; import { useSessionContext } from "@/contexts/session-context"; import { useBrowserTitle } from "@/hooks/use-browser-title"; @@ -107,6 +109,7 @@ function AppShell() { const [composeOpen, setComposeOpen] = useState(false); const [scrollLocked, setScrollLocked] = useState(false); const [hostname, setHostname] = useState(""); + const rfbInstanceRef = useRef(null); const [showCreateServerDialog, setShowCreateServerDialog] = useState(false); const [createServerName, setCreateServerName] = useState(""); const [showKillServerConfirm, setShowKillServerConfirm] = useState(false); @@ -281,6 +284,18 @@ function AppShell() { [], ); + // Create a desktop window in a session + const handleCreateDesktopWindow = useCallback( + async (session: string) => { + try { + await createDesktopWindow(session); + } catch { + // SSE will reflect + } + }, + [], + ); + // Theme const { preference: themePreference, resolved: themeResolved, themeDark, themeLight } = useTheme(); const { setTheme } = useThemeActions(); @@ -383,6 +398,13 @@ function AppShell() { if (sessionName) handleCreateWindow(sessionName); }, }, + { + id: "create-desktop", + label: "New Desktop Window", + onSelect: () => { + if (sessionName) handleCreateDesktopWindow(sessionName); + }, + }, ] : []), ...(currentWindow @@ -434,12 +456,12 @@ function AppShell() { ] : []), ], - [sessionName, currentWindow, handleCreateWindow, dialogs], + [sessionName, currentWindow, handleCreateWindow, handleCreateDesktopWindow, dialogs], ); const viewActions: PaletteAction[] = useMemo( () => [ - ...(sessionName + ...(sessionName && currentWindow?.type !== "desktop" ? [ { id: "text-input", @@ -448,13 +470,20 @@ function AppShell() { }, ] : []), + ...(sessionName && currentWindow?.type === "desktop" + ? [ + { id: "resolution-1280x720", label: "Change desktop resolution: 1280x720", onSelect: () => changeDesktopResolution(sessionName, currentWindow.index, "1280x720").catch(() => {}) }, + { id: "resolution-1920x1080", label: "Change desktop resolution: 1920x1080", onSelect: () => changeDesktopResolution(sessionName, currentWindow.index, "1920x1080").catch(() => {}) }, + { id: "resolution-2560x1440", label: "Change desktop resolution: 2560x1440", onSelect: () => changeDesktopResolution(sessionName, currentWindow.index, "2560x1440").catch(() => {}) }, + ] + : []), { id: "toggle-fixed-width", label: fixedWidth ? "View: Full Width" : "View: Fixed Width (900px)", onSelect: toggleFixedWidth, }, ], - [sessionName, fixedWidth, toggleFixedWidth], + [sessionName, currentWindow, fixedWidth, toggleFixedWidth], ); const configActions: PaletteAction[] = useMemo( @@ -540,6 +569,7 @@ function AppShell() { onToggleDrawer={() => setDrawerOpen(!drawerOpen)} onCreateSession={dialogs.openCreateDialog} onCreateWindow={handleCreateWindow} + onCreateDesktopWindow={handleCreateDesktopWindow} onOpenCompose={() => setComposeOpen((v) => !v)} /> @@ -589,31 +619,54 @@ function AppShell() { style={fixedWidth ? { maxWidth: 900, width: "100%", marginInline: "auto" } : undefined} > {sessionName && windowIndex ? ( - <> -
- navigate({ to: "/$server", params: { server }, replace: true })} - focusRef={focusTerminalRef} - scrollLocked={scrollLocked} - /> -
- {/* Bottom Bar — only on terminal pages */} -
- setComposeOpen((v) => !v)} onFocusTerminal={() => focusTerminalRef.current?.()} onScrollLockChange={setScrollLocked} /> -
- + currentWindow?.type === "desktop" ? ( + <> +
+ navigate({ to: "/$server", params: { server }, replace: true })} + onRfbRef={(rfb) => { rfbInstanceRef.current = rfb; }} + /> +
+
+ +
+ + ) : ( + <> +
+ navigate({ to: "/$server", params: { server }, replace: true })} + focusRef={focusTerminalRef} + scrollLocked={scrollLocked} + /> +
+ {/* Bottom Bar — only on terminal pages */} +
+ setComposeOpen((v) => !v)} onFocusTerminal={() => focusTerminalRef.current?.()} onScrollLockChange={setScrollLocked} /> +
+ + ) ) : ( )} diff --git a/app/frontend/src/components/breadcrumb-dropdown.tsx b/app/frontend/src/components/breadcrumb-dropdown.tsx index 60b535c3..8cbbdceb 100644 --- a/app/frontend/src/components/breadcrumb-dropdown.tsx +++ b/app/frontend/src/components/breadcrumb-dropdown.tsx @@ -7,24 +7,26 @@ type Props = { icon?: string; onNavigate?: (href: string) => void; action?: { label: string; onAction: () => void }; + actions?: { label: string; onAction: () => void }[]; triggerClassName?: string; }; -export function BreadcrumbDropdown({ items, label, icon, onNavigate, action, triggerClassName }: Props) { +export function BreadcrumbDropdown({ items, label, icon, onNavigate, action, actions, triggerClassName }: Props) { const [open, setOpen] = useState(false); const [focusedIndex, setFocusedIndex] = useState(-1); const containerRef = useRef(null); const buttonRef = useRef(null); - const actionRef = useRef(null); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); - // When action exists, index 0 = action button, indices 1..N = items. - // When no action, indices 0..N-1 = items directly. - const offset = action ? 1 : 0; + // Merge single action and actions array into one list for offset calculation. + const allActions = actions ?? (action ? [action] : []); + const offset = allActions.length; const totalCount = items.length + offset; + const actionRefs = useRef<(HTMLButtonElement | null)[]>([]); + function getFocusableRef(index: number): HTMLButtonElement | null { - if (action && index === 0) return actionRef.current; + if (index < offset) return actionRefs.current[index] ?? null; return itemRefs.current[index - offset] ?? null; } @@ -103,21 +105,24 @@ export function BreadcrumbDropdown({ items, label, icon, onNavigate, action, tri aria-label={label ? `Switch ${label}` : "Switch"} className="absolute top-full left-0 mt-1 bg-bg-primary border border-border rounded-lg shadow-2xl py-1 min-w-[160px] max-w-[240px] z-50" > - {action && ( + {allActions.length > 0 && ( <> - + {allActions.map((act, ai) => ( + + ))}
)} diff --git a/app/frontend/src/components/dashboard.test.tsx b/app/frontend/src/components/dashboard.test.tsx index 1ccd0acc..36d5e0ef 100644 --- a/app/frontend/src/components/dashboard.test.tsx +++ b/app/frontend/src/components/dashboard.test.tsx @@ -12,6 +12,7 @@ const sessions: ProjectSession[] = [ { index: 0, name: "main", + type: "terminal", worktreePath: "~/code/run-kit", activity: "active", isActiveWindow: true, @@ -24,6 +25,7 @@ const sessions: ProjectSession[] = [ { index: 1, name: "scratch", + type: "terminal", worktreePath: "~/code/run-kit", activity: "idle", isActiveWindow: false, @@ -38,6 +40,7 @@ const sessions: ProjectSession[] = [ { index: 0, name: "dev", + type: "terminal", worktreePath: "~/code/ao-server", activity: "idle", isActiveWindow: true, diff --git a/app/frontend/src/components/dashboard.tsx b/app/frontend/src/components/dashboard.tsx index 0fc68cb0..45a180fb 100644 --- a/app/frontend/src/components/dashboard.tsx +++ b/app/frontend/src/components/dashboard.tsx @@ -7,6 +7,7 @@ type DashboardProps = { onNavigate: (session: string, windowIndex: number) => void; onCreateSession: () => void; onCreateWindow: (session: string) => void; + onCreateDesktopWindow?: (session: string) => void; }; export function Dashboard({ @@ -14,6 +15,7 @@ export function Dashboard({ onNavigate, onCreateSession, onCreateWindow, + onCreateDesktopWindow, }: DashboardProps) { const [expanded, setExpanded] = useState>({}); @@ -101,6 +103,11 @@ export function Dashboard({ {win.name} + {win.type === "desktop" && ( + + Desktop + + )} {win.fabStage && ( {win.fabStage} @@ -141,16 +148,27 @@ export function Dashboard({ ); })} - {/* New Window button */} - + {/* New Window buttons */} +
+ + +
)} diff --git a/app/frontend/src/components/desktop-bottom-bar.tsx b/app/frontend/src/components/desktop-bottom-bar.tsx new file mode 100644 index 00000000..ba6bce60 --- /dev/null +++ b/app/frontend/src/components/desktop-bottom-bar.tsx @@ -0,0 +1,163 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { changeDesktopResolution } from "@/api/client"; + +type DesktopBottomBarProps = { + rfbRef: React.RefObject; + sessionName: string; + windowIndex: number; + hostname?: string; +}; + +const KBD_CLASS = + "min-h-[36px] min-w-[36px] coarse:min-h-[36px] coarse:min-w-[36px] flex items-center justify-center px-2 py-0 text-xs border border-border rounded select-none transition-colors hover:border-text-secondary active:bg-bg-card focus-visible:outline-2 focus-visible:outline-accent"; + +const RESOLUTIONS = [ + { label: "1280x720", value: "1280x720" }, + { label: "1920x1080", value: "1920x1080" }, + { label: "2560x1440", value: "2560x1440" }, +] as const; + +export function DesktopBottomBar({ rfbRef, sessionName, windowIndex, hostname }: DesktopBottomBarProps) { + const [resOpen, setResOpen] = useState(false); + const resRef = useRef(null); + + // Close resolution picker on outside click + useEffect(() => { + if (!resOpen) return; + function handleClick(e: MouseEvent) { + if (resRef.current && !resRef.current.contains(e.target as Node)) { + setResOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [resOpen]); + + // Close on Escape + useEffect(() => { + if (!resOpen) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setResOpen(false); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [resOpen]); + + const handleClipboardPaste = useCallback(async () => { + try { + const text = await navigator.clipboard.readText(); + if (text && rfbRef.current) { + rfbRef.current.clipboardPasteFrom = text; + } + } catch { + // Clipboard API unavailable or denied + } + }, [rfbRef]); + + const handleResolution = useCallback( + async (resolution: string) => { + setResOpen(false); + try { + await changeDesktopResolution(sessionName, windowIndex, resolution); + } catch { + // best-effort + } + }, + [sessionName, windowIndex], + ); + + const handleFullscreen = useCallback(() => { + const el = document.documentElement; + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } else { + el.requestFullscreen().catch(() => {}); + } + }, []); + + return ( +
+ {/* Clipboard paste */} + + +