From 5a9dc14cd1bda9745f20c439d768137c0df5e556 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Mon, 29 Sep 2025 17:14:52 -0700 Subject: [PATCH 01/18] intial commit --- images/chromium-headful/Dockerfile | 1 + server/cmd/api/api/display.go | 162 +++++ server/docs/resolution-change-alternatives.md | 191 ++++++ server/lib/oapi/oapi.go | 631 ++++++++++++++++-- server/openapi.yaml | 83 ++- 5 files changed, 982 insertions(+), 86 deletions(-) create mode 100644 server/cmd/api/api/display.go create mode 100644 server/docs/resolution-change-alternatives.md diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 9a1c9da2..11bcbf99 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -124,6 +124,7 @@ RUN set -eux; \ wget ca-certificates python2 supervisor xclip xdotool \ pulseaudio dbus-x11 xserver-xorg-video-dummy \ libcairo2 libxcb1 libxrandr2 libxv1 libopus0 libvpx7 \ + x11-xserver-utils \ gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly \ gstreamer1.0-pulseaudio gstreamer1.0-omx; \ diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go new file mode 100644 index 00000000..c5683411 --- /dev/null +++ b/server/cmd/api/api/display.go @@ -0,0 +1,162 @@ +package api + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/onkernel/kernel-images/server/lib/logger" + oapi "github.com/onkernel/kernel-images/server/lib/oapi" +) + +// DisplayStatus reports whether it is currently safe to resize the display. +// It checks for active Neko viewer sessions (approx. by counting ESTABLISHED TCP +// connections on port 8080) and whether any recording is active. +func (s *ApiService) DisplayStatus(ctx context.Context, _ oapi.DisplayStatusRequestObject) (oapi.DisplayStatusResponseObject, error) { + live := s.countEstablishedTCPSessions(ctx, 8080) + isRecording := s.anyRecordingActive(ctx) + isReplaying := false // replay not currently implemented + + resizableNow := (live == 0) && !isRecording && !isReplaying + + return oapi.DisplayStatus200JSONResponse(oapi.DisplayStatus{ + LiveViewSessions: &live, + IsRecording: &isRecording, + IsReplaying: &isReplaying, + ResizableNow: &resizableNow, + }), nil +} + +// SetResolution safely updates the current X display resolution. When require_idle +// is true (default), it refuses to resize while live view or recording/replay is active. +func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRequestObject) (oapi.SetResolutionResponseObject, error) { + log := logger.FromContext(ctx) + if req.Body == nil { + return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "missing request body"}}, nil + } + width := req.Body.Width + height := req.Body.Height + if width <= 0 || height <= 0 { + return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid width/height"}}, nil + } + requireIdle := true + if req.Body.RequireIdle != nil { + requireIdle = *req.Body.RequireIdle + } + + // Check current status + statusResp, _ := s.DisplayStatus(ctx, oapi.DisplayStatusRequestObject{}) + var status oapi.DisplayStatus + switch v := statusResp.(type) { + case oapi.DisplayStatus200JSONResponse: + status = oapi.DisplayStatus(v) + default: + // In unexpected cases, default to conservative behaviour + status = oapi.DisplayStatus{LiveViewSessions: ptrInt(0), IsRecording: ptrBool(false), IsReplaying: ptrBool(false), ResizableNow: ptrBool(true)} + } + if requireIdle && status.ResizableNow != nil && !*status.ResizableNow { + return oapi.SetResolution409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "resize refused: live view or recording/replay active"}}, nil + } + + display := s.resolveDisplayFromEnv() + args := []string{"-lc", fmt.Sprintf("xrandr -s %dx%d", width, height)} + env := map[string]string{"DISPLAY": display} + execReq := oapi.ProcessExecRequest{Command: "bash", Args: &args, Env: &env} + resp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &execReq}) + if err != nil { + return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to execute xrandr"}}, nil + } + switch r := resp.(type) { + case oapi.ProcessExec200JSONResponse: + if r.ExitCode != nil && *r.ExitCode != 0 { + var stderr string + if r.StderrB64 != nil { + if b, decErr := base64.StdEncoding.DecodeString(*r.StderrB64); decErr == nil { + stderr = strings.TrimSpace(string(b)) + } + } + if stderr == "" { + stderr = "xrandr returned non-zero exit code" + } + return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: fmt.Sprintf("failed to set resolution: %s", stderr)}}, nil + } + log.Info("resolution updated", "display", display, "width", width, "height", height) + + // Restart Chromium to ensure it adapts to the new resolution + log.Info("restarting chromium to adapt to new resolution") + restartCmd := []string{"-lc", "supervisorctl restart chromium"} + restartEnv := map[string]string{} + restartReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartCmd, Env: &restartEnv} + restartResp, restartErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartReq}) + if restartErr != nil { + log.Error("failed to restart chromium after resolution change", "error", restartErr) + // Still return success since resolution change succeeded + return oapi.SetResolution200JSONResponse{Ok: true}, nil + } + + // Check if restart succeeded + if execResp, ok := restartResp.(oapi.ProcessExec200JSONResponse); ok { + if execResp.ExitCode != nil && *execResp.ExitCode != 0 { + log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) + } else { + log.Info("chromium restarted successfully") + } + } + + return oapi.SetResolution200JSONResponse{Ok: true}, nil + case oapi.ProcessExec400JSONResponse: + return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: r.Message}}, nil + case oapi.ProcessExec500JSONResponse: + return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: r.Message}}, nil + default: + return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "unexpected response from process exec"}}, nil + } +} + +// anyRecordingActive returns true if any registered recorder is currently recording. +func (s *ApiService) anyRecordingActive(ctx context.Context) bool { + for _, r := range s.recordManager.ListActiveRecorders(ctx) { + if r.IsRecording(ctx) { + return true + } + } + return false +} + +// countEstablishedTCPSessions returns the number of ESTABLISHED TCP connections for the given local port. +// Implementation shells out to netstat, which is present in the image (net-tools). +func (s *ApiService) countEstablishedTCPSessions(ctx context.Context, port int) int { + cmd := exec.CommandContext(ctx, "/bin/bash", "-lc", fmt.Sprintf("netstat -tn 2>/dev/null | awk '$6==\"ESTABLISHED\" && $4 ~ /:%d$/ {count++} END{print count+0}'", port)) + out, err := cmd.Output() + if err != nil { + return 0 + } + val := strings.TrimSpace(string(out)) + if val == "" { + return 0 + } + i, err := strconv.Atoi(val) + if err != nil { + return 0 + } + return i +} + +// resolveDisplayFromEnv returns the X display string, defaulting to ":1". +func (s *ApiService) resolveDisplayFromEnv() string { + // Prefer KERNEL_IMAGES_API_DISPLAY_NUM, fallback to DISPLAY_NUM, default 1 + if v := strings.TrimSpace(os.Getenv("KERNEL_IMAGES_API_DISPLAY_NUM")); v != "" { + return ":" + v + } + if v := strings.TrimSpace(os.Getenv("DISPLAY_NUM")); v != "" { + return ":" + v + } + return ":1" +} + +func ptrBool(v bool) *bool { return &v } +func ptrInt(v int) *int { return &v } diff --git a/server/docs/resolution-change-alternatives.md b/server/docs/resolution-change-alternatives.md new file mode 100644 index 00000000..e5455071 --- /dev/null +++ b/server/docs/resolution-change-alternatives.md @@ -0,0 +1,191 @@ +# Alternative Methods for Window Resizing After Resolution Change + +This document describes alternative approaches to handle window resizing after changing the display resolution via xrandr, without restarting Chromium. + +## Current Implementation + +The current implementation in `display.go` restarts Chromium via supervisorctl after a resolution change to ensure the browser window adapts to the new display size. While effective, this approach disrupts the user session. + +## Alternative Approaches + +### 1. Using xdotool to Resize Windows + +**xdotool** is already installed in the image and provides precise window control. + +```bash +# Find and resize all Chromium windows to match new resolution +xdotool search --class chromium windowsize %@ 1920 1080 + +# Or move to origin and then resize +xdotool search --class chromium windowmove %@ 0 0 windowsize %@ 1920 1080 +``` + +**Implementation in Go:** +```go +resizeCmd := []string{"-lc", fmt.Sprintf("xdotool search --class chromium windowmove %%@ 0 0 windowsize %%@ %d %d", width, height)} +resizeEnv := map[string]string{"DISPLAY": display} +resizeReq := oapi.ProcessExecRequest{Command: "bash", Args: &resizeCmd, Env: &resizeEnv} +``` + +**Pros:** +- No restart needed +- Instant window resize +- Preserves session state +- Already available in the image + +**Cons:** +- May not handle all edge cases +- Window decorations might not update properly + +### 2. Using wmctrl to Re-maximize Windows + +**wmctrl** provides window manager control but needs to be installed first. + +```bash +# Install wmctrl +apt-get install -y wmctrl + +# Re-maximize all windows +wmctrl -r ':ACTIVE:' -b add,maximized_vert,maximized_horz +``` + +**Implementation in Go:** +```go +maximizeCmd := []string{"-lc", "wmctrl -r ':ACTIVE:' -b add,maximized_vert,maximized_horz"} +maximizeEnv := map[string]string{"DISPLAY": display} +maximizeReq := oapi.ProcessExecRequest{Command: "bash", Args: &maximizeCmd, Env: &maximizeEnv} +``` + +**Pros:** +- Works with window manager hints +- Clean maximization +- Respects window manager behavior + +**Cons:** +- Requires additional package installation +- May not work if window isn't already maximized + +### 3. Toggle Fullscreen Mode + +Use xdotool to send F11 key to toggle fullscreen, forcing a re-render. + +```bash +# Toggle fullscreen twice to force re-render +xdotool search --class chromium windowactivate && xdotool key F11 && sleep 0.5 && xdotool key F11 +``` + +**Implementation in Go:** +```go +fullscreenCmd := []string{"-lc", "xdotool search --class chromium windowactivate && xdotool key F11 && sleep 0.5 && xdotool key F11"} +fullscreenEnv := map[string]string{"DISPLAY": display} +fullscreenReq := oapi.ProcessExecRequest{Command: "bash", Args: &fullscreenCmd, Env: &fullscreenEnv} +``` + +**Pros:** +- Forces complete re-render +- Works with Chromium's built-in fullscreen logic +- No additional tools needed + +**Cons:** +- Visible flicker during toggle +- May interfere with actual fullscreen state +- Timing dependent + +### 4. Using Mutter's D-Bus Interface + +Since Mutter is the window manager, its D-Bus interface could be used for window management. + +```bash +# This would require more complex D-Bus commands +gdbus call --session \ + --dest org.gnome.Mutter \ + --object-path /org/gnome/Mutter \ + --method org.gnome.Mutter.ResizeWindow +``` + +**Pros:** +- Native window manager integration +- Most "correct" approach +- Handles all window manager specifics + +**Cons:** +- Complex implementation +- D-Bus interface may not be fully exposed +- Requires deeper Mutter knowledge + +### 5. JavaScript-based Resize via CDP + +Use Chrome DevTools Protocol to resize from within the browser. + +```bash +# Send CDP command to resize window +curl -X POST http://localhost:9223/json/runtime/evaluate \ + -d '{"expression": "window.resizeTo(1920, 1080)"}' +``` + +**Pros:** +- Works from within the browser context +- Can be very precise +- No external window manipulation + +**Cons:** +- Requires CDP connection +- May be blocked by browser security +- Only affects browser viewport, not window chrome + +## Recommendations + +1. **For immediate implementation**: Use xdotool (Option 1) as it's already available and provides good results. + +2. **For best user experience**: Implement a combination approach: + - Try xdotool resize first + - Fall back to restart if resize fails + - Add configuration option to choose method + +3. **For future enhancement**: + - Add wmctrl to the Docker image + - Implement proper Mutter D-Bus integration + - Allow users to configure preferred resize method + +## Example Enhanced Implementation + +```go +func (s *ApiService) resizeChromiumWindow(ctx context.Context, display string, width, height int) error { + // Try xdotool first + resizeCmd := []string{"-lc", fmt.Sprintf("xdotool search --class chromium windowmove %%@ 0 0 windowsize %%@ %d %d", width, height)} + resizeEnv := map[string]string{"DISPLAY": display} + resizeReq := oapi.ProcessExecRequest{Command: "bash", Args: &resizeCmd, Env: &resizeEnv} + + resp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &resizeReq}) + if err != nil { + return fmt.Errorf("failed to execute xdotool: %w", err) + } + + if execResp, ok := resp.(oapi.ProcessExec200JSONResponse); ok { + if execResp.ExitCode != nil && *execResp.ExitCode == 0 { + return nil // Success + } + } + + // Fall back to restart if xdotool fails + return s.restartChromium(ctx) +} +``` + +## Testing Commands + +To test these alternatives manually in a running container: + +```bash +# Get into the container +docker exec -it chromium-headful bash + +# Test xdotool resize +DISPLAY=:1 xdotool search --class chromium windowsize %@ 1920 1080 + +# Test fullscreen toggle +DISPLAY=:1 xdotool key F11; sleep 1; xdotool key F11 + +# Check current window geometry +DISPLAY=:1 xdotool search --class chromium getwindowgeometry +``` diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index f889d3fa..ab1cb828 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -128,6 +128,18 @@ type DeleteRecordingRequest struct { Id *string `json:"id,omitempty"` } +// DisplayStatus defines model for DisplayStatus. +type DisplayStatus struct { + IsRecording *bool `json:"is_recording,omitempty"` + IsReplaying *bool `json:"is_replaying,omitempty"` + + // LiveViewSessions Number of active Neko viewer sessions. + LiveViewSessions *int `json:"live_view_sessions,omitempty"` + + // ResizableNow True when no blockers are present. + ResizableNow *bool `json:"resizable_now,omitempty"` +} + // Error defines model for Error. type Error struct { Message string `json:"message"` @@ -351,6 +363,15 @@ type SetFilePermissionsRequest struct { Path string `json:"path"` } +// SetResolutionRequest defines model for SetResolutionRequest. +type SetResolutionRequest struct { + Height int `json:"height"` + + // RequireIdle If true, refuse to resize when live view or recording/replay is active. + RequireIdle *bool `json:"require_idle,omitempty"` + Width int `json:"width"` +} + // StartFsWatchRequest defines model for StartFsWatchRequest. type StartFsWatchRequest struct { // Path Directory to watch. @@ -472,6 +493,9 @@ type ClickMouseJSONRequestBody = ClickMouseRequest // MoveMouseJSONRequestBody defines body for MoveMouse for application/json ContentType. type MoveMouseJSONRequestBody = MoveMouseRequest +// SetResolutionJSONRequestBody defines body for SetResolution for application/json ContentType. +type SetResolutionJSONRequestBody = SetResolutionRequest + // CreateDirectoryJSONRequestBody defines body for CreateDirectory for application/json ContentType. type CreateDirectoryJSONRequestBody = CreateDirectoryRequest @@ -600,6 +624,14 @@ type ClientInterface interface { MoveMouse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // SetResolutionWithBody request with any body + SetResolutionWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetResolution(ctx context.Context, body SetResolutionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DisplayStatus request + DisplayStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // CreateDirectoryWithBody request with any body CreateDirectoryWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -756,6 +788,42 @@ func (c *Client) MoveMouse(ctx context.Context, body MoveMouseJSONRequestBody, r return c.Client.Do(req) } +func (c *Client) SetResolutionWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetResolutionRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetResolution(ctx context.Context, body SetResolutionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetResolutionRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DisplayStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDisplayStatusRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) CreateDirectoryWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewCreateDirectoryRequestWithBody(c.Server, contentType, body) if err != nil { @@ -1316,6 +1384,73 @@ func NewMoveMouseRequestWithBody(server string, contentType string, body io.Read return req, nil } +// NewSetResolutionRequest calls the generic SetResolution builder with application/json body +func NewSetResolutionRequest(server string, body SetResolutionJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetResolutionRequestWithBody(server, "application/json", bodyReader) +} + +// NewSetResolutionRequestWithBody generates requests for SetResolution with any type of body +func NewSetResolutionRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/display/set_resolution") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDisplayStatusRequest generates requests for DisplayStatus +func NewDisplayStatusRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/display/status") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewCreateDirectoryRequest calls the generic CreateDirectory builder with application/json body func NewCreateDirectoryRequest(server string, body CreateDirectoryJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -2509,6 +2644,14 @@ type ClientWithResponsesInterface interface { MoveMouseWithResponse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) + // SetResolutionWithBodyWithResponse request with any body + SetResolutionWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetResolutionResponse, error) + + SetResolutionWithResponse(ctx context.Context, body SetResolutionJSONRequestBody, reqEditors ...RequestEditorFn) (*SetResolutionResponse, error) + + // DisplayStatusWithResponse request + DisplayStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisplayStatusResponse, error) + // CreateDirectoryWithBodyWithResponse request with any body CreateDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) @@ -2663,6 +2806,54 @@ func (r MoveMouseResponse) StatusCode() int { return 0 } +type SetResolutionResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *OkResponse + JSON400 *BadRequestError + JSON409 *ConflictError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r SetResolutionResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetResolutionResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DisplayStatusResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DisplayStatus + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DisplayStatusResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DisplayStatusResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type CreateDirectoryResponse struct { Body []byte HTTPResponse *http.Response @@ -3348,6 +3539,32 @@ func (c *ClientWithResponses) MoveMouseWithResponse(ctx context.Context, body Mo return ParseMoveMouseResponse(rsp) } +// SetResolutionWithBodyWithResponse request with arbitrary body returning *SetResolutionResponse +func (c *ClientWithResponses) SetResolutionWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetResolutionResponse, error) { + rsp, err := c.SetResolutionWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetResolutionResponse(rsp) +} + +func (c *ClientWithResponses) SetResolutionWithResponse(ctx context.Context, body SetResolutionJSONRequestBody, reqEditors ...RequestEditorFn) (*SetResolutionResponse, error) { + rsp, err := c.SetResolution(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetResolutionResponse(rsp) +} + +// DisplayStatusWithResponse request returning *DisplayStatusResponse +func (c *ClientWithResponses) DisplayStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisplayStatusResponse, error) { + rsp, err := c.DisplayStatus(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseDisplayStatusResponse(rsp) +} + // CreateDirectoryWithBodyWithResponse request with arbitrary body returning *CreateDirectoryResponse func (c *ClientWithResponses) CreateDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) { rsp, err := c.CreateDirectoryWithBody(ctx, contentType, body, reqEditors...) @@ -3761,6 +3978,86 @@ func ParseMoveMouseResponse(rsp *http.Response) (*MoveMouseResponse, error) { return response, nil } +// ParseSetResolutionResponse parses an HTTP response from a SetResolutionWithResponse call +func ParseSetResolutionResponse(rsp *http.Response) (*SetResolutionResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetResolutionResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest OkResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ConflictError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDisplayStatusResponse parses an HTTP response from a DisplayStatusWithResponse call +func ParseDisplayStatusResponse(rsp *http.Response) (*DisplayStatusResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DisplayStatusResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DisplayStatus + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseCreateDirectoryResponse parses an HTTP response from a CreateDirectoryWithResponse call func ParseCreateDirectoryResponse(rsp *http.Response) (*CreateDirectoryResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -4849,6 +5146,12 @@ type ServerInterface interface { // Move the mouse cursor to the specified coordinates on the host computer // (POST /computer/move_mouse) MoveMouse(w http.ResponseWriter, r *http.Request) + // Safely set the X display resolution + // (POST /display/set_resolution) + SetResolution(w http.ResponseWriter, r *http.Request) + // Report whether resize is currently safe + // (GET /display/status) + DisplayStatus(w http.ResponseWriter, r *http.Request) // Create a new directory // (PUT /fs/create_directory) CreateDirectory(w http.ResponseWriter, r *http.Request) @@ -4948,6 +5251,18 @@ func (_ Unimplemented) MoveMouse(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Safely set the X display resolution +// (POST /display/set_resolution) +func (_ Unimplemented) SetResolution(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Report whether resize is currently safe +// (GET /display/status) +func (_ Unimplemented) DisplayStatus(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Create a new directory // (PUT /fs/create_directory) func (_ Unimplemented) CreateDirectory(w http.ResponseWriter, r *http.Request) { @@ -5147,6 +5462,34 @@ func (siw *ServerInterfaceWrapper) MoveMouse(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } +// SetResolution operation middleware +func (siw *ServerInterfaceWrapper) SetResolution(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SetResolution(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DisplayStatus operation middleware +func (siw *ServerInterfaceWrapper) DisplayStatus(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DisplayStatus(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // CreateDirectory operation middleware func (siw *ServerInterfaceWrapper) CreateDirectory(w http.ResponseWriter, r *http.Request) { @@ -5875,6 +6218,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/computer/move_mouse", wrapper.MoveMouse) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/display/set_resolution", wrapper.SetResolution) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/display/status", wrapper.DisplayStatus) + }) r.Group(func(r chi.Router) { r.Put(options.BaseURL+"/fs/create_directory", wrapper.CreateDirectory) }) @@ -6036,6 +6385,75 @@ func (response MoveMouse500JSONResponse) VisitMoveMouseResponse(w http.ResponseW return json.NewEncoder(w).Encode(response) } +type SetResolutionRequestObject struct { + Body *SetResolutionJSONRequestBody +} + +type SetResolutionResponseObject interface { + VisitSetResolutionResponse(w http.ResponseWriter) error +} + +type SetResolution200JSONResponse OkResponse + +func (response SetResolution200JSONResponse) VisitSetResolutionResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type SetResolution400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response SetResolution400JSONResponse) VisitSetResolutionResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type SetResolution409JSONResponse struct{ ConflictErrorJSONResponse } + +func (response SetResolution409JSONResponse) VisitSetResolutionResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type SetResolution500JSONResponse struct{ InternalErrorJSONResponse } + +func (response SetResolution500JSONResponse) VisitSetResolutionResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type DisplayStatusRequestObject struct { +} + +type DisplayStatusResponseObject interface { + VisitDisplayStatusResponse(w http.ResponseWriter) error +} + +type DisplayStatus200JSONResponse DisplayStatus + +func (response DisplayStatus200JSONResponse) VisitDisplayStatusResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type DisplayStatus500JSONResponse struct{ InternalErrorJSONResponse } + +func (response DisplayStatus500JSONResponse) VisitDisplayStatusResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type CreateDirectoryRequestObject struct { Body *CreateDirectoryJSONRequestBody } @@ -7310,6 +7728,12 @@ type StrictServerInterface interface { // Move the mouse cursor to the specified coordinates on the host computer // (POST /computer/move_mouse) MoveMouse(ctx context.Context, request MoveMouseRequestObject) (MoveMouseResponseObject, error) + // Safely set the X display resolution + // (POST /display/set_resolution) + SetResolution(ctx context.Context, request SetResolutionRequestObject) (SetResolutionResponseObject, error) + // Report whether resize is currently safe + // (GET /display/status) + DisplayStatus(ctx context.Context, request DisplayStatusRequestObject) (DisplayStatusResponseObject, error) // Create a new directory // (PUT /fs/create_directory) CreateDirectory(ctx context.Context, request CreateDirectoryRequestObject) (CreateDirectoryResponseObject, error) @@ -7484,6 +7908,61 @@ func (sh *strictHandler) MoveMouse(w http.ResponseWriter, r *http.Request) { } } +// SetResolution operation middleware +func (sh *strictHandler) SetResolution(w http.ResponseWriter, r *http.Request) { + var request SetResolutionRequestObject + + var body SetResolutionJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SetResolution(ctx, request.(SetResolutionRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SetResolution") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SetResolutionResponseObject); ok { + if err := validResponse.VisitSetResolutionResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DisplayStatus operation middleware +func (sh *strictHandler) DisplayStatus(w http.ResponseWriter, r *http.Request) { + var request DisplayStatusRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DisplayStatus(ctx, request.(DisplayStatusRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DisplayStatus") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DisplayStatusResponseObject); ok { + if err := validResponse.VisitDisplayStatusResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // CreateDirectory operation middleware func (sh *strictHandler) CreateDirectory(w http.ResponseWriter, r *http.Request) { var request CreateDirectoryRequestObject @@ -8268,80 +8747,84 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index feea875f..86c8f632 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -737,6 +737,42 @@ paths: $ref: "#/components/responses/NotFoundError" "500": $ref: "#/components/responses/InternalError" + /display/set_resolution: + post: + summary: Safely set the X display resolution + operationId: setResolution + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetResolutionRequest" + responses: + "200": + description: Resolution updated + content: + application/json: + schema: + $ref: "#/components/schemas/OkResponse" + "409": + $ref: "#/components/responses/ConflictError" + "400": + $ref: "#/components/responses/BadRequestError" + "500": + $ref: "#/components/responses/InternalError" + /display/status: + get: + summary: Report whether resize is currently safe + operationId: displayStatus + responses: + "200": + description: Status + content: + application/json: + schema: + $ref: "#/components/schemas/DisplayStatus" + "500": + $ref: "#/components/responses/InternalError" components: schemas: StartRecordingRequest: @@ -788,14 +824,12 @@ components: isRecording: type: boolean started_at: - type: string + type: [string, "null"] format: date-time - nullable: true description: Timestamp when recording started finished_at: - type: string + type: [string, "null"] format: date-time - nullable: true description: Timestamp when recording finished ClickMouseRequest: type: object @@ -986,9 +1020,8 @@ components: type: string default: [] cwd: - type: string + type: [string, "null"] description: Working directory (absolute path) to run the command in. - nullable: true pattern: "^/.*" env: type: object @@ -997,17 +1030,15 @@ components: type: string default: {} as_user: - type: string + type: [string, "null"] description: Run the process as this user. - nullable: true as_root: type: boolean description: Run the process with root privileges. default: false timeout_sec: - type: integer + type: [integer, "null"] description: Maximum execution time in seconds. - nullable: true additionalProperties: false ProcessExecResult: type: object @@ -1054,8 +1085,7 @@ components: enum: [running, exited] description: Process state. exit_code: - type: integer - nullable: true + type: [integer, "null"] description: Exit code if the process has exited. cpu_pct: type: number @@ -1111,6 +1141,35 @@ components: type: integer description: Exit code when the event is "exit". additionalProperties: false + SetResolutionRequest: + type: object + required: [width, height] + properties: + width: + type: integer + minimum: 320 + height: + type: integer + minimum: 240 + require_idle: + type: boolean + description: If true, refuse to resize when live view or recording/replay is active. + default: true + additionalProperties: false + DisplayStatus: + type: object + properties: + live_view_sessions: + type: integer + description: Number of active Neko viewer sessions. + is_recording: + type: boolean + is_replaying: + type: boolean + resizable_now: + type: boolean + description: True when no blockers are present. + additionalProperties: false LogEvent: type: object description: A log entry from the application. From d13e76463f6e5ae1ad6d1a7b88bfec15c1ab69ba Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Tue, 30 Sep 2025 11:00:15 -0700 Subject: [PATCH 02/18] support headless mode --- server/cmd/api/api/display.go | 179 +++++++++++++++++++++++++++------- 1 file changed, 144 insertions(+), 35 deletions(-) diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index c5683411..5506cbfa 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -33,6 +33,8 @@ func (s *ApiService) DisplayStatus(ctx context.Context, _ oapi.DisplayStatusRequ // SetResolution safely updates the current X display resolution. When require_idle // is true (default), it refuses to resize while live view or recording/replay is active. +// This method automatically detects whether the system is running with Xorg (headful) +// or Xvfb (headless) and uses the appropriate method to change resolution. func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRequestObject) (oapi.SetResolutionResponseObject, error) { log := logger.FromContext(ctx) if req.Body == nil { @@ -63,43 +65,155 @@ func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRe } display := s.resolveDisplayFromEnv() - args := []string{"-lc", fmt.Sprintf("xrandr -s %dx%d", width, height)} - env := map[string]string{"DISPLAY": display} - execReq := oapi.ProcessExecRequest{Command: "bash", Args: &args, Env: &env} - resp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &execReq}) - if err != nil { - return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to execute xrandr"}}, nil + + // Detect if we're using Xorg (headful) or Xvfb (headless) by checking supervisor services + // This is more reliable than checking xrandr support since xrandr might be installed + // but not functional with Xvfb + checkCmd := []string{"-lc", "supervisorctl status xvfb >/dev/null 2>&1 && echo 'xvfb' || echo 'xorg'"} + checkReq := oapi.ProcessExecRequest{Command: "bash", Args: &checkCmd} + checkResp, _ := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &checkReq}) + + isXorg := true + if execResp, ok := checkResp.(oapi.ProcessExec200JSONResponse); ok { + if execResp.StdoutB64 != nil { + if output, err := base64.StdEncoding.DecodeString(*execResp.StdoutB64); err == nil { + outputStr := strings.TrimSpace(string(output)) + if outputStr == "xvfb" { + isXorg = false + log.Info("detected Xvfb display (headless mode)") + } else { + log.Info("detected Xorg display (headful mode)") + } + } + } } - switch r := resp.(type) { - case oapi.ProcessExec200JSONResponse: - if r.ExitCode != nil && *r.ExitCode != 0 { - var stderr string - if r.StderrB64 != nil { - if b, decErr := base64.StdEncoding.DecodeString(*r.StderrB64); decErr == nil { - stderr = strings.TrimSpace(string(b)) + + if isXorg { + // Xorg path: use xrandr + args := []string{"-lc", fmt.Sprintf("xrandr -s %dx%d", width, height)} + env := map[string]string{"DISPLAY": display} + execReq := oapi.ProcessExecRequest{Command: "bash", Args: &args, Env: &env} + resp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &execReq}) + if err != nil { + return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to execute xrandr"}}, nil + } + switch r := resp.(type) { + case oapi.ProcessExec200JSONResponse: + if r.ExitCode != nil && *r.ExitCode != 0 { + var stderr string + if r.StderrB64 != nil { + if b, decErr := base64.StdEncoding.DecodeString(*r.StderrB64); decErr == nil { + stderr = strings.TrimSpace(string(b)) + } + } + if stderr == "" { + stderr = "xrandr returned non-zero exit code" } + return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: fmt.Sprintf("failed to set resolution: %s", stderr)}}, nil } - if stderr == "" { - stderr = "xrandr returned non-zero exit code" + log.Info("resolution updated via xrandr", "display", display, "width", width, "height", height) + + // Restart Chromium to ensure it adapts to the new resolution + log.Info("restarting chromium to adapt to new resolution") + restartCmd := []string{"-lc", "supervisorctl restart chromium"} + restartEnv := map[string]string{} + restartReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartCmd, Env: &restartEnv} + restartResp, restartErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartReq}) + if restartErr != nil { + log.Error("failed to restart chromium after resolution change", "error", restartErr) + // Still return success since resolution change succeeded + return oapi.SetResolution200JSONResponse{Ok: true}, nil + } + + // Check if restart succeeded + if execResp, ok := restartResp.(oapi.ProcessExec200JSONResponse); ok { + if execResp.ExitCode != nil && *execResp.ExitCode != 0 { + log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) + } else { + log.Info("chromium restarted successfully") + } } - return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: fmt.Sprintf("failed to set resolution: %s", stderr)}}, nil + + return oapi.SetResolution200JSONResponse{Ok: true}, nil + case oapi.ProcessExec400JSONResponse: + return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: r.Message}}, nil + case oapi.ProcessExec500JSONResponse: + return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: r.Message}}, nil + default: + return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "unexpected response from process exec"}}, nil } - log.Info("resolution updated", "display", display, "width", width, "height", height) - - // Restart Chromium to ensure it adapts to the new resolution - log.Info("restarting chromium to adapt to new resolution") - restartCmd := []string{"-lc", "supervisorctl restart chromium"} - restartEnv := map[string]string{} - restartReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartCmd, Env: &restartEnv} - restartResp, restartErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartReq}) - if restartErr != nil { - log.Error("failed to restart chromium after resolution change", "error", restartErr) - // Still return success since resolution change succeeded + } else { + // Xvfb path: restart with new dimensions + log.Info("updating Xvfb resolution requires restart", "width", width, "height", height) + + // Update supervisor config to include environment variables + // First, remove any existing environment line to avoid duplicates + log.Info("updating xvfb supervisor config with new dimensions") + removeEnvCmd := []string{"-lc", `sed -i '/^environment=/d' /etc/supervisor/conf.d/services/xvfb.conf`} + removeEnvReq := oapi.ProcessExecRequest{Command: "bash", Args: &removeEnvCmd} + s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &removeEnvReq}) + + // Now add the environment line with WIDTH and HEIGHT + addEnvCmd := []string{"-lc", fmt.Sprintf(`sed -i '/\[program:xvfb\]/a environment=WIDTH="%d",HEIGHT="%d",DPI="96",DISPLAY=":1"' /etc/supervisor/conf.d/services/xvfb.conf`, width, height)} + addEnvReq := oapi.ProcessExecRequest{Command: "bash", Args: &addEnvCmd} + configResp, configErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &addEnvReq}) + if configErr != nil { + return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to update xvfb config"}}, nil + } + + // Check if config update succeeded + if execResp, ok := configResp.(oapi.ProcessExec200JSONResponse); ok { + if execResp.ExitCode != nil && *execResp.ExitCode != 0 { + log.Error("failed to update xvfb config", "exit_code", *execResp.ExitCode) + return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to update xvfb config"}}, nil + } + } + + // Reload supervisor configuration + log.Info("reloading supervisor configuration") + reloadCmd := []string{"-lc", "supervisorctl reread && supervisorctl update"} + reloadReq := oapi.ProcessExecRequest{Command: "bash", Args: &reloadCmd} + _, reloadErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &reloadReq}) + if reloadErr != nil { + log.Error("failed to reload supervisor config", "error", reloadErr) + } + + // Restart xvfb with new configuration + log.Info("restarting xvfb with new resolution") + restartXvfbCmd := []string{"-lc", "supervisorctl restart xvfb"} + restartXvfbReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartXvfbCmd} + xvfbResp, xvfbErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartXvfbReq}) + if xvfbErr != nil { + return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to restart Xvfb"}}, nil + } + + // Check if Xvfb restart succeeded + if execResp, ok := xvfbResp.(oapi.ProcessExec200JSONResponse); ok { + if execResp.ExitCode != nil && *execResp.ExitCode != 0 { + return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "Xvfb restart failed"}}, nil + } + } + + // Wait for Xvfb to be ready + log.Info("waiting for Xvfb to be ready") + waitCmd := []string{"-lc", "sleep 2"} + waitReq := oapi.ProcessExecRequest{Command: "bash", Args: &waitCmd} + s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &waitReq}) + + // Restart Chromium + log.Info("restarting chromium after Xvfb restart") + restartChromeCmd := []string{"-lc", "supervisorctl restart chromium"} + restartChromeEnv := map[string]string{} + restartChromeReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartChromeCmd, Env: &restartChromeEnv} + chromeResp, chromeErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartChromeReq}) + if chromeErr != nil { + log.Error("failed to restart chromium after Xvfb restart", "error", chromeErr) + // Still return success since Xvfb restart succeeded return oapi.SetResolution200JSONResponse{Ok: true}, nil } - // Check if restart succeeded - if execResp, ok := restartResp.(oapi.ProcessExec200JSONResponse); ok { + // Check if Chromium restart succeeded + if execResp, ok := chromeResp.(oapi.ProcessExec200JSONResponse); ok { if execResp.ExitCode != nil && *execResp.ExitCode != 0 { log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) } else { @@ -107,13 +221,8 @@ func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRe } } + log.Info("Xvfb resolution updated", "display", display, "width", width, "height", height) return oapi.SetResolution200JSONResponse{Ok: true}, nil - case oapi.ProcessExec400JSONResponse: - return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: r.Message}}, nil - case oapi.ProcessExec500JSONResponse: - return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: r.Message}}, nil - default: - return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "unexpected response from process exec"}}, nil } } From fc572892d080e1ec41e03db4770f12a7ba9e2f69 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Thu, 2 Oct 2025 17:50:20 -0700 Subject: [PATCH 03/18] Sayan's comments --- server/cmd/api/api/display.go | 267 +++++++-- server/docs/resolution-change-alternatives.md | 191 ------- server/lib/oapi/oapi.go | 515 ++++++------------ server/openapi.yaml | 44 +- 4 files changed, 406 insertions(+), 611 deletions(-) delete mode 100644 server/docs/resolution-change-alternatives.md diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index 5506cbfa..033fc2db 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -3,65 +3,86 @@ package api import ( "context" "encoding/base64" + "encoding/json" "fmt" + "io" + "net/http" "os" "os/exec" "strconv" "strings" + "time" "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" ) -// DisplayStatus reports whether it is currently safe to resize the display. -// It checks for active Neko viewer sessions (approx. by counting ESTABLISHED TCP -// connections on port 8080) and whether any recording is active. -func (s *ApiService) DisplayStatus(ctx context.Context, _ oapi.DisplayStatusRequestObject) (oapi.DisplayStatusResponseObject, error) { - live := s.countEstablishedTCPSessions(ctx, 8080) - isRecording := s.anyRecordingActive(ctx) - isReplaying := false // replay not currently implemented - - resizableNow := (live == 0) && !isRecording && !isReplaying - - return oapi.DisplayStatus200JSONResponse(oapi.DisplayStatus{ - LiveViewSessions: &live, - IsRecording: &isRecording, - IsReplaying: &isReplaying, - ResizableNow: &resizableNow, - }), nil -} - -// SetResolution safely updates the current X display resolution. When require_idle +// PatchDisplay updates the display configuration. When require_idle // is true (default), it refuses to resize while live view or recording/replay is active. // This method automatically detects whether the system is running with Xorg (headful) // or Xvfb (headless) and uses the appropriate method to change resolution. -func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRequestObject) (oapi.SetResolutionResponseObject, error) { +func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequestObject) (oapi.PatchDisplayResponseObject, error) { log := logger.FromContext(ctx) if req.Body == nil { - return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "missing request body"}}, nil + return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "missing request body"}}, nil } - width := req.Body.Width - height := req.Body.Height + + // Check if resolution change is requested + if req.Body.Width == nil && req.Body.Height == nil { + return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no display parameters to update"}}, nil + } + + // Get current resolution if only one dimension is provided + currentWidth, currentHeight := s.getCurrentResolution(ctx) + width := currentWidth + height := currentHeight + + if req.Body.Width != nil { + width = *req.Body.Width + } + if req.Body.Height != nil { + height = *req.Body.Height + } + if width <= 0 || height <= 0 { - return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid width/height"}}, nil + return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid width/height"}}, nil } requireIdle := true if req.Body.RequireIdle != nil { requireIdle = *req.Body.RequireIdle } - // Check current status - statusResp, _ := s.DisplayStatus(ctx, oapi.DisplayStatusRequestObject{}) - var status oapi.DisplayStatus - switch v := statusResp.(type) { - case oapi.DisplayStatus200JSONResponse: - status = oapi.DisplayStatus(v) - default: - // In unexpected cases, default to conservative behaviour - status = oapi.DisplayStatus{LiveViewSessions: ptrInt(0), IsRecording: ptrBool(false), IsReplaying: ptrBool(false), ResizableNow: ptrBool(true)} + // Check current status if required + if requireIdle { + live := s.getActiveNekoSessions(ctx) + isRecording := s.anyRecordingActive(ctx) + isReplaying := false // replay not currently implemented + resizableNow := (live == 0) && !isRecording && !isReplaying + + if !resizableNow { + return oapi.PatchDisplay409JSONResponse{ + ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{ + Message: "resize refused: live view or recording/replay active", + }, + }, nil + } } - if requireIdle && status.ResizableNow != nil && !*status.ResizableNow { - return oapi.SetResolution409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "resize refused: live view or recording/replay active"}}, nil + + // When Neko is enabled, delegate resolution changes to its API + // Neko handles all the complexity of restarting X.org/Xvfb and Chromium + if s.isNekoEnabled() { + log.Info("delegating resolution change to Neko API", "width", width, "height", height) + + if err := s.setResolutionViaNeko(ctx, width, height); err != nil { + log.Error("failed to change resolution via Neko API, falling back to direct method", "error", err) + // Fall through to direct implementation + } else { + // Successfully changed via Neko + return oapi.PatchDisplay200JSONResponse{ + Width: &width, + Height: &height, + }, nil + } } display := s.resolveDisplayFromEnv() @@ -95,7 +116,7 @@ func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRe execReq := oapi.ProcessExecRequest{Command: "bash", Args: &args, Env: &env} resp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &execReq}) if err != nil { - return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to execute xrandr"}}, nil + return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to execute xrandr"}}, nil } switch r := resp.(type) { case oapi.ProcessExec200JSONResponse: @@ -109,7 +130,7 @@ func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRe if stderr == "" { stderr = "xrandr returned non-zero exit code" } - return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: fmt.Sprintf("failed to set resolution: %s", stderr)}}, nil + return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: fmt.Sprintf("failed to set resolution: %s", stderr)}}, nil } log.Info("resolution updated via xrandr", "display", display, "width", width, "height", height) @@ -122,7 +143,11 @@ func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRe if restartErr != nil { log.Error("failed to restart chromium after resolution change", "error", restartErr) // Still return success since resolution change succeeded - return oapi.SetResolution200JSONResponse{Ok: true}, nil + // Return success with the new dimensions + return oapi.PatchDisplay200JSONResponse{ + Width: &width, + Height: &height, + }, nil } // Check if restart succeeded @@ -134,13 +159,17 @@ func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRe } } - return oapi.SetResolution200JSONResponse{Ok: true}, nil + // Return success with the new dimensions + return oapi.PatchDisplay200JSONResponse{ + Width: &width, + Height: &height, + }, nil case oapi.ProcessExec400JSONResponse: - return oapi.SetResolution400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: r.Message}}, nil + return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: r.Message}}, nil case oapi.ProcessExec500JSONResponse: - return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: r.Message}}, nil + return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: r.Message}}, nil default: - return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "unexpected response from process exec"}}, nil + return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "unexpected response from process exec"}}, nil } } else { // Xvfb path: restart with new dimensions @@ -158,14 +187,14 @@ func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRe addEnvReq := oapi.ProcessExecRequest{Command: "bash", Args: &addEnvCmd} configResp, configErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &addEnvReq}) if configErr != nil { - return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to update xvfb config"}}, nil + return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to update xvfb config"}}, nil } // Check if config update succeeded if execResp, ok := configResp.(oapi.ProcessExec200JSONResponse); ok { if execResp.ExitCode != nil && *execResp.ExitCode != 0 { log.Error("failed to update xvfb config", "exit_code", *execResp.ExitCode) - return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to update xvfb config"}}, nil + return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to update xvfb config"}}, nil } } @@ -184,13 +213,13 @@ func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRe restartXvfbReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartXvfbCmd} xvfbResp, xvfbErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartXvfbReq}) if xvfbErr != nil { - return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to restart Xvfb"}}, nil + return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to restart Xvfb"}}, nil } // Check if Xvfb restart succeeded if execResp, ok := xvfbResp.(oapi.ProcessExec200JSONResponse); ok { if execResp.ExitCode != nil && *execResp.ExitCode != 0 { - return oapi.SetResolution500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "Xvfb restart failed"}}, nil + return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "Xvfb restart failed"}}, nil } } @@ -209,7 +238,11 @@ func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRe if chromeErr != nil { log.Error("failed to restart chromium after Xvfb restart", "error", chromeErr) // Still return success since Xvfb restart succeeded - return oapi.SetResolution200JSONResponse{Ok: true}, nil + // Return success with the new dimensions + return oapi.PatchDisplay200JSONResponse{ + Width: &width, + Height: &height, + }, nil } // Check if Chromium restart succeeded @@ -222,7 +255,11 @@ func (s *ApiService) SetResolution(ctx context.Context, req oapi.SetResolutionRe } log.Info("Xvfb resolution updated", "display", display, "width", width, "height", height) - return oapi.SetResolution200JSONResponse{Ok: true}, nil + // Return success with the new dimensions + return oapi.PatchDisplay200JSONResponse{ + Width: &width, + Height: &height, + }, nil } } @@ -236,8 +273,43 @@ func (s *ApiService) anyRecordingActive(ctx context.Context) bool { return false } +// getActiveNekoSessions queries the Neko API for active viewer sessions. +// It falls back to counting TCP connections if the API is unavailable. +func (s *ApiService) getActiveNekoSessions(ctx context.Context) int { + log := logger.FromContext(ctx) + + // Create HTTP client with short timeout + client := &http.Client{ + Timeout: 200 * time.Millisecond, + } + + // Query Neko API + resp, err := client.Get("http://localhost:8080/api/sessions") + if err != nil { + log.Debug("failed to query Neko API, falling back to TCP counting", "error", err) + return s.countEstablishedTCPSessions(ctx, 8080) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + log.Debug("Neko API returned non-OK status, falling back to TCP counting", "status", resp.StatusCode) + return s.countEstablishedTCPSessions(ctx, 8080) + } + + // Parse response + var sessions []map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { + log.Debug("failed to parse Neko API response, falling back to TCP counting", "error", err) + return s.countEstablishedTCPSessions(ctx, 8080) + } + + log.Debug("successfully queried Neko API", "active_sessions", len(sessions)) + return len(sessions) +} + // countEstablishedTCPSessions returns the number of ESTABLISHED TCP connections for the given local port. -// Implementation shells out to netstat, which is present in the image (net-tools). +// This is used as a fallback when the Neko API is unavailable. func (s *ApiService) countEstablishedTCPSessions(ctx context.Context, port int) int { cmd := exec.CommandContext(ctx, "/bin/bash", "-lc", fmt.Sprintf("netstat -tn 2>/dev/null | awk '$6==\"ESTABLISHED\" && $4 ~ /:%d$/ {count++} END{print count+0}'", port)) out, err := cmd.Output() @@ -267,5 +339,98 @@ func (s *ApiService) resolveDisplayFromEnv() string { return ":1" } -func ptrBool(v bool) *bool { return &v } -func ptrInt(v int) *int { return &v } +// getCurrentResolution returns the current display resolution by querying xrandr +func (s *ApiService) getCurrentResolution(ctx context.Context) (int, int) { + log := logger.FromContext(ctx) + display := s.resolveDisplayFromEnv() + + // Use xrandr to get current resolution + cmd := exec.CommandContext(ctx, "bash", "-lc", "xrandr | grep -E '\\*' | awk '{print $1}'") + cmd.Env = append(os.Environ(), fmt.Sprintf("DISPLAY=%s", display)) + + out, err := cmd.Output() + if err != nil { + log.Error("failed to get current resolution", "error", err) + // Return default resolution on error + return 1024, 768 + } + + resStr := strings.TrimSpace(string(out)) + parts := strings.Split(resStr, "x") + if len(parts) != 2 { + log.Error("unexpected xrandr output format", "output", resStr) + return 1024, 768 + } + + width, err := strconv.Atoi(parts[0]) + if err != nil { + log.Error("failed to parse width", "error", err, "value", parts[0]) + return 1024, 768 + } + + height, err := strconv.Atoi(parts[1]) + if err != nil { + log.Error("failed to parse height", "error", err, "value", parts[1]) + return 1024, 768 + } + + return width, height +} + +// isNekoEnabled checks if Neko service is enabled +func (s *ApiService) isNekoEnabled() bool { + return os.Getenv("ENABLE_WEBRTC") == "true" +} + +// setResolutionViaNeko delegates resolution change to Neko API +func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height int) error { + log := logger.FromContext(ctx) + + client := &http.Client{ + Timeout: 5 * time.Second, + } + + // Prepare request body for Neko's screen API + screenConfig := map[string]interface{}{ + "width": width, + "height": height, + "rate": 60, // Default refresh rate + } + + body, err := json.Marshal(screenConfig) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // Create request + req, err := http.NewRequestWithContext(ctx, "POST", + "http://localhost:8080/api/room/screen", + strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + // Add authentication - Neko requires admin credentials + adminPassword := os.Getenv("NEKO_ADMIN_PASSWORD") + if adminPassword == "" { + adminPassword = "admin" // Default from neko.yaml + } + req.SetBasicAuth("admin", adminPassword) + + // Execute request + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to call Neko API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Neko API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + log.Info("successfully changed resolution via Neko API", "width", width, "height", height) + return nil +} diff --git a/server/docs/resolution-change-alternatives.md b/server/docs/resolution-change-alternatives.md deleted file mode 100644 index e5455071..00000000 --- a/server/docs/resolution-change-alternatives.md +++ /dev/null @@ -1,191 +0,0 @@ -# Alternative Methods for Window Resizing After Resolution Change - -This document describes alternative approaches to handle window resizing after changing the display resolution via xrandr, without restarting Chromium. - -## Current Implementation - -The current implementation in `display.go` restarts Chromium via supervisorctl after a resolution change to ensure the browser window adapts to the new display size. While effective, this approach disrupts the user session. - -## Alternative Approaches - -### 1. Using xdotool to Resize Windows - -**xdotool** is already installed in the image and provides precise window control. - -```bash -# Find and resize all Chromium windows to match new resolution -xdotool search --class chromium windowsize %@ 1920 1080 - -# Or move to origin and then resize -xdotool search --class chromium windowmove %@ 0 0 windowsize %@ 1920 1080 -``` - -**Implementation in Go:** -```go -resizeCmd := []string{"-lc", fmt.Sprintf("xdotool search --class chromium windowmove %%@ 0 0 windowsize %%@ %d %d", width, height)} -resizeEnv := map[string]string{"DISPLAY": display} -resizeReq := oapi.ProcessExecRequest{Command: "bash", Args: &resizeCmd, Env: &resizeEnv} -``` - -**Pros:** -- No restart needed -- Instant window resize -- Preserves session state -- Already available in the image - -**Cons:** -- May not handle all edge cases -- Window decorations might not update properly - -### 2. Using wmctrl to Re-maximize Windows - -**wmctrl** provides window manager control but needs to be installed first. - -```bash -# Install wmctrl -apt-get install -y wmctrl - -# Re-maximize all windows -wmctrl -r ':ACTIVE:' -b add,maximized_vert,maximized_horz -``` - -**Implementation in Go:** -```go -maximizeCmd := []string{"-lc", "wmctrl -r ':ACTIVE:' -b add,maximized_vert,maximized_horz"} -maximizeEnv := map[string]string{"DISPLAY": display} -maximizeReq := oapi.ProcessExecRequest{Command: "bash", Args: &maximizeCmd, Env: &maximizeEnv} -``` - -**Pros:** -- Works with window manager hints -- Clean maximization -- Respects window manager behavior - -**Cons:** -- Requires additional package installation -- May not work if window isn't already maximized - -### 3. Toggle Fullscreen Mode - -Use xdotool to send F11 key to toggle fullscreen, forcing a re-render. - -```bash -# Toggle fullscreen twice to force re-render -xdotool search --class chromium windowactivate && xdotool key F11 && sleep 0.5 && xdotool key F11 -``` - -**Implementation in Go:** -```go -fullscreenCmd := []string{"-lc", "xdotool search --class chromium windowactivate && xdotool key F11 && sleep 0.5 && xdotool key F11"} -fullscreenEnv := map[string]string{"DISPLAY": display} -fullscreenReq := oapi.ProcessExecRequest{Command: "bash", Args: &fullscreenCmd, Env: &fullscreenEnv} -``` - -**Pros:** -- Forces complete re-render -- Works with Chromium's built-in fullscreen logic -- No additional tools needed - -**Cons:** -- Visible flicker during toggle -- May interfere with actual fullscreen state -- Timing dependent - -### 4. Using Mutter's D-Bus Interface - -Since Mutter is the window manager, its D-Bus interface could be used for window management. - -```bash -# This would require more complex D-Bus commands -gdbus call --session \ - --dest org.gnome.Mutter \ - --object-path /org/gnome/Mutter \ - --method org.gnome.Mutter.ResizeWindow -``` - -**Pros:** -- Native window manager integration -- Most "correct" approach -- Handles all window manager specifics - -**Cons:** -- Complex implementation -- D-Bus interface may not be fully exposed -- Requires deeper Mutter knowledge - -### 5. JavaScript-based Resize via CDP - -Use Chrome DevTools Protocol to resize from within the browser. - -```bash -# Send CDP command to resize window -curl -X POST http://localhost:9223/json/runtime/evaluate \ - -d '{"expression": "window.resizeTo(1920, 1080)"}' -``` - -**Pros:** -- Works from within the browser context -- Can be very precise -- No external window manipulation - -**Cons:** -- Requires CDP connection -- May be blocked by browser security -- Only affects browser viewport, not window chrome - -## Recommendations - -1. **For immediate implementation**: Use xdotool (Option 1) as it's already available and provides good results. - -2. **For best user experience**: Implement a combination approach: - - Try xdotool resize first - - Fall back to restart if resize fails - - Add configuration option to choose method - -3. **For future enhancement**: - - Add wmctrl to the Docker image - - Implement proper Mutter D-Bus integration - - Allow users to configure preferred resize method - -## Example Enhanced Implementation - -```go -func (s *ApiService) resizeChromiumWindow(ctx context.Context, display string, width, height int) error { - // Try xdotool first - resizeCmd := []string{"-lc", fmt.Sprintf("xdotool search --class chromium windowmove %%@ 0 0 windowsize %%@ %d %d", width, height)} - resizeEnv := map[string]string{"DISPLAY": display} - resizeReq := oapi.ProcessExecRequest{Command: "bash", Args: &resizeCmd, Env: &resizeEnv} - - resp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &resizeReq}) - if err != nil { - return fmt.Errorf("failed to execute xdotool: %w", err) - } - - if execResp, ok := resp.(oapi.ProcessExec200JSONResponse); ok { - if execResp.ExitCode != nil && *execResp.ExitCode == 0 { - return nil // Success - } - } - - // Fall back to restart if xdotool fails - return s.restartChromium(ctx) -} -``` - -## Testing Commands - -To test these alternatives manually in a running container: - -```bash -# Get into the container -docker exec -it chromium-headful bash - -# Test xdotool resize -DISPLAY=:1 xdotool search --class chromium windowsize %@ 1920 1080 - -# Test fullscreen toggle -DISPLAY=:1 xdotool key F11; sleep 1; xdotool key F11 - -# Check current window geometry -DISPLAY=:1 xdotool search --class chromium getwindowgeometry -``` diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index ab1cb828..c0a1eb52 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -128,16 +128,25 @@ type DeleteRecordingRequest struct { Id *string `json:"id,omitempty"` } -// DisplayStatus defines model for DisplayStatus. -type DisplayStatus struct { +// DisplayConfig defines model for DisplayConfig. +type DisplayConfig struct { + // Height Current display height in pixels + Height *int `json:"height,omitempty"` + + // IsRecording Whether recording is currently active IsRecording *bool `json:"is_recording,omitempty"` + + // IsReplaying Whether replay is currently active IsReplaying *bool `json:"is_replaying,omitempty"` // LiveViewSessions Number of active Neko viewer sessions. LiveViewSessions *int `json:"live_view_sessions,omitempty"` - // ResizableNow True when no blockers are present. + // ResizableNow True when no blockers are present for resizing ResizableNow *bool `json:"resizable_now,omitempty"` + + // Width Current display width in pixels + Width *int `json:"width,omitempty"` } // Error defines model for Error. @@ -223,6 +232,18 @@ type OkResponse struct { Ok bool `json:"ok"` } +// PatchDisplayRequest defines model for PatchDisplayRequest. +type PatchDisplayRequest struct { + // Height Display height in pixels + Height *int `json:"height,omitempty"` + + // RequireIdle If true, refuse to resize when live view or recording/replay is active. + RequireIdle *bool `json:"require_idle,omitempty"` + + // Width Display width in pixels + Width *int `json:"width,omitempty"` +} + // ProcessExecRequest Request to execute a command synchronously. type ProcessExecRequest struct { // Args Command arguments. @@ -363,15 +384,6 @@ type SetFilePermissionsRequest struct { Path string `json:"path"` } -// SetResolutionRequest defines model for SetResolutionRequest. -type SetResolutionRequest struct { - Height int `json:"height"` - - // RequireIdle If true, refuse to resize when live view or recording/replay is active. - RequireIdle *bool `json:"require_idle,omitempty"` - Width int `json:"width"` -} - // StartFsWatchRequest defines model for StartFsWatchRequest. type StartFsWatchRequest struct { // Path Directory to watch. @@ -493,8 +505,8 @@ type ClickMouseJSONRequestBody = ClickMouseRequest // MoveMouseJSONRequestBody defines body for MoveMouse for application/json ContentType. type MoveMouseJSONRequestBody = MoveMouseRequest -// SetResolutionJSONRequestBody defines body for SetResolution for application/json ContentType. -type SetResolutionJSONRequestBody = SetResolutionRequest +// PatchDisplayJSONRequestBody defines body for PatchDisplay for application/json ContentType. +type PatchDisplayJSONRequestBody = PatchDisplayRequest // CreateDirectoryJSONRequestBody defines body for CreateDirectory for application/json ContentType. type CreateDirectoryJSONRequestBody = CreateDirectoryRequest @@ -624,13 +636,10 @@ type ClientInterface interface { MoveMouse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // SetResolutionWithBody request with any body - SetResolutionWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - SetResolution(ctx context.Context, body SetResolutionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // PatchDisplayWithBody request with any body + PatchDisplayWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - // DisplayStatus request - DisplayStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + PatchDisplay(ctx context.Context, body PatchDisplayJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // CreateDirectoryWithBody request with any body CreateDirectoryWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -788,20 +797,8 @@ func (c *Client) MoveMouse(ctx context.Context, body MoveMouseJSONRequestBody, r return c.Client.Do(req) } -func (c *Client) SetResolutionWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewSetResolutionRequestWithBody(c.Server, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -func (c *Client) SetResolution(ctx context.Context, body SetResolutionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewSetResolutionRequest(c.Server, body) +func (c *Client) PatchDisplayWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPatchDisplayRequestWithBody(c.Server, contentType, body) if err != nil { return nil, err } @@ -812,8 +809,8 @@ func (c *Client) SetResolution(ctx context.Context, body SetResolutionJSONReques return c.Client.Do(req) } -func (c *Client) DisplayStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewDisplayStatusRequest(c.Server) +func (c *Client) PatchDisplay(ctx context.Context, body PatchDisplayJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPatchDisplayRequest(c.Server, body) if err != nil { return nil, err } @@ -1384,19 +1381,19 @@ func NewMoveMouseRequestWithBody(server string, contentType string, body io.Read return req, nil } -// NewSetResolutionRequest calls the generic SetResolution builder with application/json body -func NewSetResolutionRequest(server string, body SetResolutionJSONRequestBody) (*http.Request, error) { +// NewPatchDisplayRequest calls the generic PatchDisplay builder with application/json body +func NewPatchDisplayRequest(server string, body PatchDisplayJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader buf, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(buf) - return NewSetResolutionRequestWithBody(server, "application/json", bodyReader) + return NewPatchDisplayRequestWithBody(server, "application/json", bodyReader) } -// NewSetResolutionRequestWithBody generates requests for SetResolution with any type of body -func NewSetResolutionRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { +// NewPatchDisplayRequestWithBody generates requests for PatchDisplay with any type of body +func NewPatchDisplayRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -1404,7 +1401,7 @@ func NewSetResolutionRequestWithBody(server string, contentType string, body io. return nil, err } - operationPath := fmt.Sprintf("/display/set_resolution") + operationPath := fmt.Sprintf("/display") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -1414,7 +1411,7 @@ func NewSetResolutionRequestWithBody(server string, contentType string, body io. return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest("PATCH", queryURL.String(), body) if err != nil { return nil, err } @@ -1424,33 +1421,6 @@ func NewSetResolutionRequestWithBody(server string, contentType string, body io. return req, nil } -// NewDisplayStatusRequest generates requests for DisplayStatus -func NewDisplayStatusRequest(server string) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/display/status") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("GET", queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - // NewCreateDirectoryRequest calls the generic CreateDirectory builder with application/json body func NewCreateDirectoryRequest(server string, body CreateDirectoryJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -2644,13 +2614,10 @@ type ClientWithResponsesInterface interface { MoveMouseWithResponse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) - // SetResolutionWithBodyWithResponse request with any body - SetResolutionWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetResolutionResponse, error) + // PatchDisplayWithBodyWithResponse request with any body + PatchDisplayWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchDisplayResponse, error) - SetResolutionWithResponse(ctx context.Context, body SetResolutionJSONRequestBody, reqEditors ...RequestEditorFn) (*SetResolutionResponse, error) - - // DisplayStatusWithResponse request - DisplayStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisplayStatusResponse, error) + PatchDisplayWithResponse(ctx context.Context, body PatchDisplayJSONRequestBody, reqEditors ...RequestEditorFn) (*PatchDisplayResponse, error) // CreateDirectoryWithBodyWithResponse request with any body CreateDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) @@ -2806,17 +2773,17 @@ func (r MoveMouseResponse) StatusCode() int { return 0 } -type SetResolutionResponse struct { +type PatchDisplayResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *OkResponse + JSON200 *DisplayConfig JSON400 *BadRequestError JSON409 *ConflictError JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r SetResolutionResponse) Status() string { +func (r PatchDisplayResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -2824,30 +2791,7 @@ func (r SetResolutionResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r SetResolutionResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -type DisplayStatusResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *DisplayStatus - JSON500 *InternalError -} - -// Status returns HTTPResponse.Status -func (r DisplayStatusResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r DisplayStatusResponse) StatusCode() int { +func (r PatchDisplayResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } @@ -3539,30 +3483,21 @@ func (c *ClientWithResponses) MoveMouseWithResponse(ctx context.Context, body Mo return ParseMoveMouseResponse(rsp) } -// SetResolutionWithBodyWithResponse request with arbitrary body returning *SetResolutionResponse -func (c *ClientWithResponses) SetResolutionWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetResolutionResponse, error) { - rsp, err := c.SetResolutionWithBody(ctx, contentType, body, reqEditors...) +// PatchDisplayWithBodyWithResponse request with arbitrary body returning *PatchDisplayResponse +func (c *ClientWithResponses) PatchDisplayWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchDisplayResponse, error) { + rsp, err := c.PatchDisplayWithBody(ctx, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseSetResolutionResponse(rsp) + return ParsePatchDisplayResponse(rsp) } -func (c *ClientWithResponses) SetResolutionWithResponse(ctx context.Context, body SetResolutionJSONRequestBody, reqEditors ...RequestEditorFn) (*SetResolutionResponse, error) { - rsp, err := c.SetResolution(ctx, body, reqEditors...) +func (c *ClientWithResponses) PatchDisplayWithResponse(ctx context.Context, body PatchDisplayJSONRequestBody, reqEditors ...RequestEditorFn) (*PatchDisplayResponse, error) { + rsp, err := c.PatchDisplay(ctx, body, reqEditors...) if err != nil { return nil, err } - return ParseSetResolutionResponse(rsp) -} - -// DisplayStatusWithResponse request returning *DisplayStatusResponse -func (c *ClientWithResponses) DisplayStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DisplayStatusResponse, error) { - rsp, err := c.DisplayStatus(ctx, reqEditors...) - if err != nil { - return nil, err - } - return ParseDisplayStatusResponse(rsp) + return ParsePatchDisplayResponse(rsp) } // CreateDirectoryWithBodyWithResponse request with arbitrary body returning *CreateDirectoryResponse @@ -3978,22 +3913,22 @@ func ParseMoveMouseResponse(rsp *http.Response) (*MoveMouseResponse, error) { return response, nil } -// ParseSetResolutionResponse parses an HTTP response from a SetResolutionWithResponse call -func ParseSetResolutionResponse(rsp *http.Response) (*SetResolutionResponse, error) { +// ParsePatchDisplayResponse parses an HTTP response from a PatchDisplayWithResponse call +func ParsePatchDisplayResponse(rsp *http.Response) (*PatchDisplayResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &SetResolutionResponse{ + response := &PatchDisplayResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest OkResponse + var dest DisplayConfig if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -4025,39 +3960,6 @@ func ParseSetResolutionResponse(rsp *http.Response) (*SetResolutionResponse, err return response, nil } -// ParseDisplayStatusResponse parses an HTTP response from a DisplayStatusWithResponse call -func ParseDisplayStatusResponse(rsp *http.Response) (*DisplayStatusResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &DisplayStatusResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest DisplayStatus - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: - var dest InternalError - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON500 = &dest - - } - - return response, nil -} - // ParseCreateDirectoryResponse parses an HTTP response from a CreateDirectoryWithResponse call func ParseCreateDirectoryResponse(rsp *http.Response) (*CreateDirectoryResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -5146,12 +5048,9 @@ type ServerInterface interface { // Move the mouse cursor to the specified coordinates on the host computer // (POST /computer/move_mouse) MoveMouse(w http.ResponseWriter, r *http.Request) - // Safely set the X display resolution - // (POST /display/set_resolution) - SetResolution(w http.ResponseWriter, r *http.Request) - // Report whether resize is currently safe - // (GET /display/status) - DisplayStatus(w http.ResponseWriter, r *http.Request) + // Update display configuration + // (PATCH /display) + PatchDisplay(w http.ResponseWriter, r *http.Request) // Create a new directory // (PUT /fs/create_directory) CreateDirectory(w http.ResponseWriter, r *http.Request) @@ -5251,15 +5150,9 @@ func (_ Unimplemented) MoveMouse(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// Safely set the X display resolution -// (POST /display/set_resolution) -func (_ Unimplemented) SetResolution(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// Report whether resize is currently safe -// (GET /display/status) -func (_ Unimplemented) DisplayStatus(w http.ResponseWriter, r *http.Request) { +// Update display configuration +// (PATCH /display) +func (_ Unimplemented) PatchDisplay(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } @@ -5462,25 +5355,11 @@ func (siw *ServerInterfaceWrapper) MoveMouse(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } -// SetResolution operation middleware -func (siw *ServerInterfaceWrapper) SetResolution(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.SetResolution(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// DisplayStatus operation middleware -func (siw *ServerInterfaceWrapper) DisplayStatus(w http.ResponseWriter, r *http.Request) { +// PatchDisplay operation middleware +func (siw *ServerInterfaceWrapper) PatchDisplay(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DisplayStatus(w, r) + siw.Handler.PatchDisplay(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -6219,10 +6098,7 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Post(options.BaseURL+"/computer/move_mouse", wrapper.MoveMouse) }) r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/display/set_resolution", wrapper.SetResolution) - }) - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/display/status", wrapper.DisplayStatus) + r.Patch(options.BaseURL+"/display", wrapper.PatchDisplay) }) r.Group(func(r chi.Router) { r.Put(options.BaseURL+"/fs/create_directory", wrapper.CreateDirectory) @@ -6385,69 +6261,44 @@ func (response MoveMouse500JSONResponse) VisitMoveMouseResponse(w http.ResponseW return json.NewEncoder(w).Encode(response) } -type SetResolutionRequestObject struct { - Body *SetResolutionJSONRequestBody +type PatchDisplayRequestObject struct { + Body *PatchDisplayJSONRequestBody } -type SetResolutionResponseObject interface { - VisitSetResolutionResponse(w http.ResponseWriter) error +type PatchDisplayResponseObject interface { + VisitPatchDisplayResponse(w http.ResponseWriter) error } -type SetResolution200JSONResponse OkResponse +type PatchDisplay200JSONResponse DisplayConfig -func (response SetResolution200JSONResponse) VisitSetResolutionResponse(w http.ResponseWriter) error { +func (response PatchDisplay200JSONResponse) VisitPatchDisplayResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type SetResolution400JSONResponse struct{ BadRequestErrorJSONResponse } +type PatchDisplay400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response SetResolution400JSONResponse) VisitSetResolutionResponse(w http.ResponseWriter) error { +func (response PatchDisplay400JSONResponse) VisitPatchDisplayResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type SetResolution409JSONResponse struct{ ConflictErrorJSONResponse } +type PatchDisplay409JSONResponse struct{ ConflictErrorJSONResponse } -func (response SetResolution409JSONResponse) VisitSetResolutionResponse(w http.ResponseWriter) error { +func (response PatchDisplay409JSONResponse) VisitPatchDisplayResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(409) return json.NewEncoder(w).Encode(response) } -type SetResolution500JSONResponse struct{ InternalErrorJSONResponse } +type PatchDisplay500JSONResponse struct{ InternalErrorJSONResponse } -func (response SetResolution500JSONResponse) VisitSetResolutionResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(500) - - return json.NewEncoder(w).Encode(response) -} - -type DisplayStatusRequestObject struct { -} - -type DisplayStatusResponseObject interface { - VisitDisplayStatusResponse(w http.ResponseWriter) error -} - -type DisplayStatus200JSONResponse DisplayStatus - -func (response DisplayStatus200JSONResponse) VisitDisplayStatusResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type DisplayStatus500JSONResponse struct{ InternalErrorJSONResponse } - -func (response DisplayStatus500JSONResponse) VisitDisplayStatusResponse(w http.ResponseWriter) error { +func (response PatchDisplay500JSONResponse) VisitPatchDisplayResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) @@ -7728,12 +7579,9 @@ type StrictServerInterface interface { // Move the mouse cursor to the specified coordinates on the host computer // (POST /computer/move_mouse) MoveMouse(ctx context.Context, request MoveMouseRequestObject) (MoveMouseResponseObject, error) - // Safely set the X display resolution - // (POST /display/set_resolution) - SetResolution(ctx context.Context, request SetResolutionRequestObject) (SetResolutionResponseObject, error) - // Report whether resize is currently safe - // (GET /display/status) - DisplayStatus(ctx context.Context, request DisplayStatusRequestObject) (DisplayStatusResponseObject, error) + // Update display configuration + // (PATCH /display) + PatchDisplay(ctx context.Context, request PatchDisplayRequestObject) (PatchDisplayResponseObject, error) // Create a new directory // (PUT /fs/create_directory) CreateDirectory(ctx context.Context, request CreateDirectoryRequestObject) (CreateDirectoryResponseObject, error) @@ -7908,11 +7756,11 @@ func (sh *strictHandler) MoveMouse(w http.ResponseWriter, r *http.Request) { } } -// SetResolution operation middleware -func (sh *strictHandler) SetResolution(w http.ResponseWriter, r *http.Request) { - var request SetResolutionRequestObject +// PatchDisplay operation middleware +func (sh *strictHandler) PatchDisplay(w http.ResponseWriter, r *http.Request) { + var request PatchDisplayRequestObject - var body SetResolutionJSONRequestBody + var body PatchDisplayJSONRequestBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) return @@ -7920,42 +7768,18 @@ func (sh *strictHandler) SetResolution(w http.ResponseWriter, r *http.Request) { request.Body = &body handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.SetResolution(ctx, request.(SetResolutionRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "SetResolution") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(SetResolutionResponseObject); ok { - if err := validResponse.VisitSetResolutionResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} - -// DisplayStatus operation middleware -func (sh *strictHandler) DisplayStatus(w http.ResponseWriter, r *http.Request) { - var request DisplayStatusRequestObject - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.DisplayStatus(ctx, request.(DisplayStatusRequestObject)) + return sh.ssi.PatchDisplay(ctx, request.(PatchDisplayRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "DisplayStatus") + handler = middleware(handler, "PatchDisplay") } response, err := handler(r.Context(), w, r, request) if err != nil { sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(DisplayStatusResponseObject); ok { - if err := validResponse.VisitDisplayStatusResponse(w); err != nil { + } else if validResponse, ok := response.(PatchDisplayResponseObject); ok { + if err := validResponse.VisitPatchDisplayResponse(w); err != nil { sh.options.ResponseErrorHandlerFunc(w, r, err) } } else if response != nil { @@ -8747,84 +8571,85 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "RP4vAAD///S81cFWbQAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index 86c8f632..9b399117 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -737,42 +737,29 @@ paths: $ref: "#/components/responses/NotFoundError" "500": $ref: "#/components/responses/InternalError" - /display/set_resolution: - post: - summary: Safely set the X display resolution - operationId: setResolution + /display: + patch: + summary: Update display configuration + operationId: patchDisplay requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/SetResolutionRequest" + $ref: "#/components/schemas/PatchDisplayRequest" responses: "200": - description: Resolution updated + description: Updated display configuration content: application/json: schema: - $ref: "#/components/schemas/OkResponse" + $ref: "#/components/schemas/DisplayConfig" "409": $ref: "#/components/responses/ConflictError" "400": $ref: "#/components/responses/BadRequestError" "500": $ref: "#/components/responses/InternalError" - /display/status: - get: - summary: Report whether resize is currently safe - operationId: displayStatus - responses: - "200": - description: Status - content: - application/json: - schema: - $ref: "#/components/schemas/DisplayStatus" - "500": - $ref: "#/components/responses/InternalError" components: schemas: StartRecordingRequest: @@ -1141,34 +1128,43 @@ components: type: integer description: Exit code when the event is "exit". additionalProperties: false - SetResolutionRequest: + PatchDisplayRequest: type: object - required: [width, height] properties: width: type: integer minimum: 320 + description: Display width in pixels height: type: integer minimum: 240 + description: Display height in pixels require_idle: type: boolean description: If true, refuse to resize when live view or recording/replay is active. default: true additionalProperties: false - DisplayStatus: + DisplayConfig: type: object properties: + width: + type: integer + description: Current display width in pixels + height: + type: integer + description: Current display height in pixels live_view_sessions: type: integer description: Number of active Neko viewer sessions. is_recording: type: boolean + description: Whether recording is currently active is_replaying: type: boolean + description: Whether replay is currently active resizable_now: type: boolean - description: True when no blockers are present. + description: True when no blockers are present for resizing additionalProperties: false LogEvent: type: object From f4747f6543179c75a028da3f043526f35da1117c Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Mon, 6 Oct 2025 21:41:28 -0400 Subject: [PATCH 04/18] moar refactoring --- images/chromium-headful/xorg.conf | 6 +- server/cmd/api/api/api.go | 4 + server/cmd/api/api/display.go | 589 ++++++++++++++++++++---------- server/lib/oapi/oapi.go | 179 +++++---- server/openapi.yaml | 10 + 5 files changed, 509 insertions(+), 279 deletions(-) diff --git a/images/chromium-headful/xorg.conf b/images/chromium-headful/xorg.conf index ed0e8488..89594ad6 100644 --- a/images/chromium-headful/xorg.conf +++ b/images/chromium-headful/xorg.conf @@ -52,6 +52,10 @@ Section "Monitor" # 1920x1080 @ 60.00 Hz (GTF) hsync: 67.08 kHz; pclk: 172.80 MHz Modeline "1920x1080_60.00" 172.80 1920 2040 2248 2576 1080 1081 1084 1118 -HSync +Vsync + # 1920x1200 @ 60.00 Hz (GTF) hsync: 74.52 kHz; pclk: 193.25 MHz + Modeline "1920x1200_60.00" 193.25 1920 2056 2256 2592 1200 1203 1209 1242 -HSync +Vsync + # 1440x900 @ 60.00 Hz (GTF) hsync: 55.92 kHz; pclk: 106.29 MHz + Modeline "1440x900_60.00" 106.29 1440 1520 1672 1904 900 901 904 932 -HSync +Vsync # 1280x720 @ 60.00 Hz (GTF) hsync: 44.76 kHz; pclk: 74.48 MHz Modeline "1280x720_60.00" 74.48 1280 1336 1472 1664 720 721 724 746 -HSync +Vsync # 1152x648 @ 60.00 Hz (GTF) hsync: 40.26 kHz; pclk: 59.91 MHz @@ -105,7 +109,7 @@ Section "Screen" SubSection "Display" Viewport 0 0 Depth 24 - Modes "1920x1080_60.00" "1280x720_60.00" "1152x648_60.00" "1024x576_60.00" "960x720_60.00" "800x600_60.00" "2560x1440_30.00" "1920x1080_30.00" "1368x768_30.00" "1280x720_30.00" "1152x648_30.00" "1024x576_30.00" "960x720_30.00" "800x600_30.00" "3840x2160_25.00" "2560x1440_25.00" "1920x1080_25.00" "1600x900_25.00" "1368x768_25.00" "800x1600_30.00" "800x1600_25.00" + Modes "1920x1080_60.00" "1920x1200_60.00" "1440x900_60.00" "1280x720_60.00" "1152x648_60.00" "1024x576_60.00" "960x720_60.00" "800x600_60.00" "2560x1440_30.00" "1920x1080_30.00" "1368x768_30.00" "1280x720_30.00" "1152x648_30.00" "1024x576_30.00" "960x720_30.00" "800x600_30.00" "3840x2160_25.00" "2560x1440_25.00" "1920x1080_25.00" "1600x900_25.00" "1368x768_25.00" "800x1600_30.00" "800x1600_25.00" EndSubSection EndSection diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 36aa3acb..d844840f 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -26,6 +26,10 @@ type ApiService struct { // Process management procMu sync.RWMutex procs map[string]*processHandle + + // Neko authentication + nekoTokenMu sync.RWMutex + nekoToken string } var _ oapi.StrictServerInterface = (*ApiService)(nil) diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index 033fc2db..bbaf2a56 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -32,10 +32,11 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no display parameters to update"}}, nil } - // Get current resolution if only one dimension is provided - currentWidth, currentHeight := s.getCurrentResolution(ctx) + // Get current resolution with refresh rate + currentWidth, currentHeight, currentRefreshRate := s.getCurrentResolution(ctx) width := currentWidth height := currentHeight + refreshRate := currentRefreshRate if req.Body.Width != nil { width = *req.Body.Width @@ -43,22 +44,31 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ if req.Body.Height != nil { height = *req.Body.Height } + if req.Body.RefreshRate != nil { + refreshRate = int(*req.Body.RefreshRate) + } if width <= 0 || height <= 0 { return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid width/height"}}, nil } + + log.Info("resolution change requested", "width", width, "height", height, "refresh_rate", refreshRate) + + // Parse requireIdle flag (default true) requireIdle := true if req.Body.RequireIdle != nil { requireIdle = *req.Body.RequireIdle } - // Check current status if required + // Check if resize is safe (no active sessions or recordings) if requireIdle { live := s.getActiveNekoSessions(ctx) isRecording := s.anyRecordingActive(ctx) isReplaying := false // replay not currently implemented resizableNow := (live == 0) && !isRecording && !isReplaying + log.Info("checking if resize is safe", "live_sessions", live, "is_recording", isRecording, "is_replaying", isReplaying, "resizable", resizableNow) + if !resizableNow { return oapi.PatchDisplay409JSONResponse{ ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{ @@ -68,199 +78,225 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ } } - // When Neko is enabled, delegate resolution changes to its API - // Neko handles all the complexity of restarting X.org/Xvfb and Chromium - if s.isNekoEnabled() { - log.Info("delegating resolution change to Neko API", "width", width, "height", height) + // Detect display mode (xorg or xvfb) + displayMode := s.detectDisplayMode(ctx) + + // Parse restartChromium flag (default depends on mode) + restartChrome := (displayMode == "xvfb") // default true for xvfb, false for xorg + if req.Body.RestartChromium != nil { + restartChrome = *req.Body.RestartChromium + } - if err := s.setResolutionViaNeko(ctx, width, height); err != nil { - log.Error("failed to change resolution via Neko API, falling back to direct method", "error", err) - // Fall through to direct implementation + // Route to appropriate resolution change handler + var err error + if displayMode == "xorg" { + if s.isNekoEnabled() { + log.Info("using Neko API for Xorg resolution change") + err = s.setResolutionXorgViaNeko(ctx, width, height, refreshRate, restartChrome) } else { - // Successfully changed via Neko - return oapi.PatchDisplay200JSONResponse{ - Width: &width, - Height: &height, - }, nil + log.Info("using xrandr for Xorg resolution change (Neko disabled)") + err = s.setResolutionXorgViaXrandr(ctx, width, height, refreshRate, restartChrome) } + } else { + log.Info("using Xvfb restart for resolution change") + err = s.setResolutionXvfb(ctx, width, height, restartChrome) } - display := s.resolveDisplayFromEnv() + if err != nil { + log.Error("failed to change resolution", "error", err) + return oapi.PatchDisplay500JSONResponse{ + InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{ + Message: fmt.Sprintf("failed to change resolution: %s", err.Error()), + }, + }, nil + } + + // Return success with the new dimensions + return oapi.PatchDisplay200JSONResponse{ + Width: &width, + Height: &height, + }, nil +} - // Detect if we're using Xorg (headful) or Xvfb (headless) by checking supervisor services - // This is more reliable than checking xrandr support since xrandr might be installed - // but not functional with Xvfb +// detectDisplayMode detects whether we're running Xorg (headful) or Xvfb (headless) +func (s *ApiService) detectDisplayMode(ctx context.Context) string { + log := logger.FromContext(ctx) checkCmd := []string{"-lc", "supervisorctl status xvfb >/dev/null 2>&1 && echo 'xvfb' || echo 'xorg'"} checkReq := oapi.ProcessExecRequest{Command: "bash", Args: &checkCmd} checkResp, _ := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &checkReq}) - isXorg := true if execResp, ok := checkResp.(oapi.ProcessExec200JSONResponse); ok { if execResp.StdoutB64 != nil { if output, err := base64.StdEncoding.DecodeString(*execResp.StdoutB64); err == nil { outputStr := strings.TrimSpace(string(output)) if outputStr == "xvfb" { - isXorg = false log.Info("detected Xvfb display (headless mode)") - } else { - log.Info("detected Xorg display (headful mode)") + return "xvfb" } } } } + log.Info("detected Xorg display (headful mode)") + return "xorg" +} - if isXorg { - // Xorg path: use xrandr - args := []string{"-lc", fmt.Sprintf("xrandr -s %dx%d", width, height)} - env := map[string]string{"DISPLAY": display} - execReq := oapi.ProcessExecRequest{Command: "bash", Args: &args, Env: &env} - resp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &execReq}) - if err != nil { - return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to execute xrandr"}}, nil +// setResolutionXorgViaNeko changes resolution for Xorg using Neko API +func (s *ApiService) setResolutionXorgViaNeko(ctx context.Context, width, height, refreshRate int, restartChrome bool) error { + log := logger.FromContext(ctx) + + if err := s.setResolutionViaNeko(ctx, width, height, refreshRate); err != nil { + return fmt.Errorf("failed to change resolution via Neko API: %w", err) + } + + if restartChrome { + log.Info("restarting chromium after resolution change") + restartCmd := []string{"-lc", "supervisorctl restart chromium"} + restartReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartCmd} + if restartResp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartReq}); err != nil { + log.Error("failed to restart chromium", "error", err) + } else if execResp, ok := restartResp.(oapi.ProcessExec200JSONResponse); ok { + if execResp.ExitCode != nil && *execResp.ExitCode != 0 { + log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) + } } - switch r := resp.(type) { - case oapi.ProcessExec200JSONResponse: - if r.ExitCode != nil && *r.ExitCode != 0 { - var stderr string - if r.StderrB64 != nil { - if b, decErr := base64.StdEncoding.DecodeString(*r.StderrB64); decErr == nil { - stderr = strings.TrimSpace(string(b)) - } - } - if stderr == "" { - stderr = "xrandr returned non-zero exit code" + } + + return nil +} + +// setResolutionXorgViaXrandr changes resolution for Xorg using xrandr (fallback when Neko is disabled) +func (s *ApiService) setResolutionXorgViaXrandr(ctx context.Context, width, height, refreshRate int, restartChrome bool) error { + log := logger.FromContext(ctx) + display := s.resolveDisplayFromEnv() + + // Build xrandr command - if refresh rate is specified, use the specific modeline + var xrandrCmd string + if refreshRate > 0 { + modeName := fmt.Sprintf("%dx%d_%d.00", width, height, refreshRate) + xrandrCmd = fmt.Sprintf("xrandr --output default --mode %s", modeName) + log.Info("using specific modeline", "mode", modeName) + } else { + xrandrCmd = fmt.Sprintf("xrandr -s %dx%d", width, height) + } + + args := []string{"-lc", xrandrCmd} + env := map[string]string{"DISPLAY": display} + execReq := oapi.ProcessExecRequest{Command: "bash", Args: &args, Env: &env} + resp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &execReq}) + if err != nil { + return fmt.Errorf("failed to execute xrandr: %w", err) + } + + switch r := resp.(type) { + case oapi.ProcessExec200JSONResponse: + if r.ExitCode != nil && *r.ExitCode != 0 { + var stderr string + if r.StderrB64 != nil { + if b, decErr := base64.StdEncoding.DecodeString(*r.StderrB64); decErr == nil { + stderr = strings.TrimSpace(string(b)) } - return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: fmt.Sprintf("failed to set resolution: %s", stderr)}}, nil } - log.Info("resolution updated via xrandr", "display", display, "width", width, "height", height) - - // Restart Chromium to ensure it adapts to the new resolution - log.Info("restarting chromium to adapt to new resolution") - restartCmd := []string{"-lc", "supervisorctl restart chromium"} - restartEnv := map[string]string{} - restartReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartCmd, Env: &restartEnv} - restartResp, restartErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartReq}) - if restartErr != nil { - log.Error("failed to restart chromium after resolution change", "error", restartErr) - // Still return success since resolution change succeeded - // Return success with the new dimensions - return oapi.PatchDisplay200JSONResponse{ - Width: &width, - Height: &height, - }, nil + if stderr == "" { + stderr = "xrandr returned non-zero exit code" } + return fmt.Errorf("xrandr failed: %s", stderr) + } + log.Info("resolution updated via xrandr", "display", display, "width", width, "height", height) - // Check if restart succeeded - if execResp, ok := restartResp.(oapi.ProcessExec200JSONResponse); ok { + if restartChrome { + log.Info("restarting chromium after resolution change") + restartCmd := []string{"-lc", "supervisorctl restart chromium"} + restartReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartCmd} + if restartResp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartReq}); err != nil { + log.Error("failed to restart chromium", "error", err) + } else if execResp, ok := restartResp.(oapi.ProcessExec200JSONResponse); ok { if execResp.ExitCode != nil && *execResp.ExitCode != 0 { log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) - } else { - log.Info("chromium restarted successfully") } } - - // Return success with the new dimensions - return oapi.PatchDisplay200JSONResponse{ - Width: &width, - Height: &height, - }, nil - case oapi.ProcessExec400JSONResponse: - return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: r.Message}}, nil - case oapi.ProcessExec500JSONResponse: - return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: r.Message}}, nil - default: - return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "unexpected response from process exec"}}, nil - } - } else { - // Xvfb path: restart with new dimensions - log.Info("updating Xvfb resolution requires restart", "width", width, "height", height) - - // Update supervisor config to include environment variables - // First, remove any existing environment line to avoid duplicates - log.Info("updating xvfb supervisor config with new dimensions") - removeEnvCmd := []string{"-lc", `sed -i '/^environment=/d' /etc/supervisor/conf.d/services/xvfb.conf`} - removeEnvReq := oapi.ProcessExecRequest{Command: "bash", Args: &removeEnvCmd} - s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &removeEnvReq}) - - // Now add the environment line with WIDTH and HEIGHT - addEnvCmd := []string{"-lc", fmt.Sprintf(`sed -i '/\[program:xvfb\]/a environment=WIDTH="%d",HEIGHT="%d",DPI="96",DISPLAY=":1"' /etc/supervisor/conf.d/services/xvfb.conf`, width, height)} - addEnvReq := oapi.ProcessExecRequest{Command: "bash", Args: &addEnvCmd} - configResp, configErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &addEnvReq}) - if configErr != nil { - return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to update xvfb config"}}, nil } + return nil + case oapi.ProcessExec400JSONResponse: + return fmt.Errorf("bad request: %s", r.Message) + case oapi.ProcessExec500JSONResponse: + return fmt.Errorf("internal error: %s", r.Message) + default: + return fmt.Errorf("unexpected response from process exec") + } +} - // Check if config update succeeded - if execResp, ok := configResp.(oapi.ProcessExec200JSONResponse); ok { - if execResp.ExitCode != nil && *execResp.ExitCode != 0 { - log.Error("failed to update xvfb config", "exit_code", *execResp.ExitCode) - return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to update xvfb config"}}, nil - } - } +// setResolutionXvfb changes resolution for Xvfb by updating config and restarting services +func (s *ApiService) setResolutionXvfb(ctx context.Context, width, height int, restartChrome bool) error { + log := logger.FromContext(ctx) + log.Info("updating Xvfb resolution requires restart", "width", width, "height", height) + + // Update supervisor config to include environment variables + log.Info("updating xvfb supervisor config with new dimensions") + removeEnvCmd := []string{"-lc", `sed -i '/^environment=/d' /etc/supervisor/conf.d/services/xvfb.conf`} + removeEnvReq := oapi.ProcessExecRequest{Command: "bash", Args: &removeEnvCmd} + s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &removeEnvReq}) + + // Add the environment line with WIDTH and HEIGHT + addEnvCmd := []string{"-lc", fmt.Sprintf(`sed -i '/\[program:xvfb\]/a environment=WIDTH="%d",HEIGHT="%d",DPI="96",DISPLAY=":1"' /etc/supervisor/conf.d/services/xvfb.conf`, width, height)} + addEnvReq := oapi.ProcessExecRequest{Command: "bash", Args: &addEnvCmd} + configResp, configErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &addEnvReq}) + if configErr != nil { + return fmt.Errorf("failed to update xvfb config: %w", configErr) + } - // Reload supervisor configuration - log.Info("reloading supervisor configuration") - reloadCmd := []string{"-lc", "supervisorctl reread && supervisorctl update"} - reloadReq := oapi.ProcessExecRequest{Command: "bash", Args: &reloadCmd} - _, reloadErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &reloadReq}) - if reloadErr != nil { - log.Error("failed to reload supervisor config", "error", reloadErr) + // Check if config update succeeded + if execResp, ok := configResp.(oapi.ProcessExec200JSONResponse); ok { + if execResp.ExitCode != nil && *execResp.ExitCode != 0 { + log.Error("failed to update xvfb config", "exit_code", *execResp.ExitCode) + return fmt.Errorf("failed to update xvfb config") } + } - // Restart xvfb with new configuration - log.Info("restarting xvfb with new resolution") - restartXvfbCmd := []string{"-lc", "supervisorctl restart xvfb"} - restartXvfbReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartXvfbCmd} - xvfbResp, xvfbErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartXvfbReq}) - if xvfbErr != nil { - return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to restart Xvfb"}}, nil - } + // Reload supervisor configuration + log.Info("reloading supervisor configuration") + reloadCmd := []string{"-lc", "supervisorctl reread && supervisorctl update"} + reloadReq := oapi.ProcessExecRequest{Command: "bash", Args: &reloadCmd} + if _, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &reloadReq}); err != nil { + log.Error("failed to reload supervisor config", "error", err) + } - // Check if Xvfb restart succeeded - if execResp, ok := xvfbResp.(oapi.ProcessExec200JSONResponse); ok { - if execResp.ExitCode != nil && *execResp.ExitCode != 0 { - return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "Xvfb restart failed"}}, nil - } + // Restart xvfb with new configuration + log.Info("restarting xvfb with new resolution") + restartXvfbCmd := []string{"-lc", "supervisorctl restart xvfb"} + restartXvfbReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartXvfbCmd} + xvfbResp, xvfbErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartXvfbReq}) + if xvfbErr != nil { + return fmt.Errorf("failed to restart Xvfb: %w", xvfbErr) + } + + // Check if Xvfb restart succeeded + if execResp, ok := xvfbResp.(oapi.ProcessExec200JSONResponse); ok { + if execResp.ExitCode != nil && *execResp.ExitCode != 0 { + return fmt.Errorf("Xvfb restart failed") } + } - // Wait for Xvfb to be ready - log.Info("waiting for Xvfb to be ready") - waitCmd := []string{"-lc", "sleep 2"} - waitReq := oapi.ProcessExecRequest{Command: "bash", Args: &waitCmd} - s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &waitReq}) + // Wait for Xvfb to be ready + log.Info("waiting for Xvfb to be ready") + waitCmd := []string{"-lc", "sleep 2"} + waitReq := oapi.ProcessExecRequest{Command: "bash", Args: &waitCmd} + s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &waitReq}) - // Restart Chromium + if restartChrome { log.Info("restarting chromium after Xvfb restart") restartChromeCmd := []string{"-lc", "supervisorctl restart chromium"} - restartChromeEnv := map[string]string{} - restartChromeReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartChromeCmd, Env: &restartChromeEnv} - chromeResp, chromeErr := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartChromeReq}) - if chromeErr != nil { - log.Error("failed to restart chromium after Xvfb restart", "error", chromeErr) - // Still return success since Xvfb restart succeeded - // Return success with the new dimensions - return oapi.PatchDisplay200JSONResponse{ - Width: &width, - Height: &height, - }, nil - } - - // Check if Chromium restart succeeded - if execResp, ok := chromeResp.(oapi.ProcessExec200JSONResponse); ok { + restartChromeReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartChromeCmd} + if chromeResp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartChromeReq}); err != nil { + log.Error("failed to restart chromium", "error", err) + } else if execResp, ok := chromeResp.(oapi.ProcessExec200JSONResponse); ok { if execResp.ExitCode != nil && *execResp.ExitCode != 0 { log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) - } else { - log.Info("chromium restarted successfully") } } - - log.Info("Xvfb resolution updated", "display", display, "width", width, "height", height) - // Return success with the new dimensions - return oapi.PatchDisplay200JSONResponse{ - Width: &width, - Height: &height, - }, nil } + + log.Info("Xvfb resolution updated", "width", width, "height", height) + return nil } // anyRecordingActive returns true if any registered recorder is currently recording. @@ -274,57 +310,73 @@ func (s *ApiService) anyRecordingActive(ctx context.Context) bool { } // getActiveNekoSessions queries the Neko API for active viewer sessions. -// It falls back to counting TCP connections if the API is unavailable. func (s *ApiService) getActiveNekoSessions(ctx context.Context) int { log := logger.FromContext(ctx) + // Get authentication token + token, err := s.getNekoToken(ctx) + if err != nil { + log.Debug("failed to get Neko token", "error", err) + return 0 + } + // Create HTTP client with short timeout client := &http.Client{ - Timeout: 200 * time.Millisecond, + Timeout: 500 * time.Millisecond, } - // Query Neko API - resp, err := client.Get("http://localhost:8080/api/sessions") + // Query Neko sessions API + req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:8080/api/sessions", nil) if err != nil { - log.Debug("failed to query Neko API, falling back to TCP counting", "error", err) - return s.countEstablishedTCPSessions(ctx, 8080) + log.Debug("failed to create Neko API request", "error", err) + return 0 + } + + // Add Bearer token authentication + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + resp, err := client.Do(req) + if err != nil { + log.Debug("failed to query Neko API", "error", err) + return 0 } defer resp.Body.Close() // Check response status + if resp.StatusCode == http.StatusUnauthorized { + log.Warn("Neko API returned 401, clearing cached token") + s.clearNekoToken() + return 0 + } if resp.StatusCode != http.StatusOK { - log.Debug("Neko API returned non-OK status, falling back to TCP counting", "status", resp.StatusCode) - return s.countEstablishedTCPSessions(ctx, 8080) + log.Warn("Neko API returned non-OK status", "status", resp.StatusCode) + return 0 } // Parse response var sessions []map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { - log.Debug("failed to parse Neko API response, falling back to TCP counting", "error", err) - return s.countEstablishedTCPSessions(ctx, 8080) - } - - log.Debug("successfully queried Neko API", "active_sessions", len(sessions)) - return len(sessions) -} - -// countEstablishedTCPSessions returns the number of ESTABLISHED TCP connections for the given local port. -// This is used as a fallback when the Neko API is unavailable. -func (s *ApiService) countEstablishedTCPSessions(ctx context.Context, port int) int { - cmd := exec.CommandContext(ctx, "/bin/bash", "-lc", fmt.Sprintf("netstat -tn 2>/dev/null | awk '$6==\"ESTABLISHED\" && $4 ~ /:%d$/ {count++} END{print count+0}'", port)) - out, err := cmd.Output() - if err != nil { - return 0 - } - val := strings.TrimSpace(string(out)) - if val == "" { + log.Error("failed to parse Neko API response", "error", err) return 0 } - i, err := strconv.Atoi(val) - if err != nil { - return 0 + + // Debug: log each session to understand what we're counting + live := 0 + for i, session := range sessions { + log.Info("neko session details", "index", i, "session", session) + if stRaw, ok := session["state"]; ok { + if st, ok := stRaw.(map[string]interface{}); ok { + connected, _ := st["is_connected"].(bool) + watching, _ := st["is_watching"].(bool) + if connected && watching { + live++ + } + } + } } - return i + + log.Info("successfully queried Neko API", "active_sessions", live) + return live } // resolveDisplayFromEnv returns the X display string, defaulting to ":1". @@ -339,8 +391,8 @@ func (s *ApiService) resolveDisplayFromEnv() string { return ":1" } -// getCurrentResolution returns the current display resolution by querying xrandr -func (s *ApiService) getCurrentResolution(ctx context.Context) (int, int) { +// getCurrentResolution returns the current display resolution and refresh rate by querying xrandr +func (s *ApiService) getCurrentResolution(ctx context.Context) (int, int, int) { log := logger.FromContext(ctx) display := s.resolveDisplayFromEnv() @@ -352,29 +404,41 @@ func (s *ApiService) getCurrentResolution(ctx context.Context) (int, int) { if err != nil { log.Error("failed to get current resolution", "error", err) // Return default resolution on error - return 1024, 768 + return 1024, 768, 60 } resStr := strings.TrimSpace(string(out)) parts := strings.Split(resStr, "x") if len(parts) != 2 { log.Error("unexpected xrandr output format", "output", resStr) - return 1024, 768 + return 1024, 768, 60 } width, err := strconv.Atoi(parts[0]) if err != nil { log.Error("failed to parse width", "error", err, "value", parts[0]) - return 1024, 768 + return 1024, 768, 60 + } + + // Parse height and refresh rate (e.g., "1080_60.00" -> height=1080, rate=60) + heightStr := parts[1] + refreshRate := 60 // default + if idx := strings.Index(heightStr, "_"); idx != -1 { + rateStr := heightStr[idx+1:] + heightStr = heightStr[:idx] + // Parse the refresh rate (e.g., "60.00" -> 60) + if rateFloat, err := strconv.ParseFloat(rateStr, 64); err == nil { + refreshRate = int(rateFloat) + } } - height, err := strconv.Atoi(parts[1]) + height, err := strconv.Atoi(heightStr) if err != nil { - log.Error("failed to parse height", "error", err, "value", parts[1]) - return 1024, 768 + log.Error("failed to parse height", "error", err, "value", heightStr) + return 1024, 768, 60 } - return width, height + return width, height, refreshRate } // isNekoEnabled checks if Neko service is enabled @@ -382,19 +446,123 @@ func (s *ApiService) isNekoEnabled() bool { return os.Getenv("ENABLE_WEBRTC") == "true" } +// getNekoToken obtains a bearer token from Neko API for authentication. +// It caches the token and reuses it for subsequent requests. +func (s *ApiService) getNekoToken(ctx context.Context) (string, error) { + log := logger.FromContext(ctx) + + // Check if we have a cached token + s.nekoTokenMu.RLock() + cachedToken := s.nekoToken + s.nekoTokenMu.RUnlock() + + if cachedToken != "" { + return cachedToken, nil + } + + // Need to obtain a new token + s.nekoTokenMu.Lock() + defer s.nekoTokenMu.Unlock() + + // Double-check in case another goroutine just obtained the token + if s.nekoToken != "" { + return s.nekoToken, nil + } + + // Get admin credentials + adminPassword := os.Getenv("NEKO_ADMIN_PASSWORD") + if adminPassword == "" { + adminPassword = "admin" // Default from neko.yaml + } + + // Prepare login request + loginReq := map[string]string{ + "username": "admin", + "password": adminPassword, + } + + loginBody, err := json.Marshal(loginReq) + if err != nil { + return "", fmt.Errorf("failed to marshal login request: %w", err) + } + + // Create HTTP client + client := &http.Client{ + Timeout: 5 * time.Second, + } + + // Call login endpoint + req, err := http.NewRequestWithContext(ctx, "POST", + "http://localhost:8080/api/login", + strings.NewReader(string(loginBody))) + if err != nil { + return "", fmt.Errorf("failed to create login request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to call login API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("login API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + // Parse response to get token + var loginResp map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return "", fmt.Errorf("failed to parse login response: %w", err) + } + + log.Debug("neko login response", "response", loginResp) + + token, ok := loginResp["token"].(string) + if !ok || token == "" { + return "", fmt.Errorf("login response did not contain a token") + } + + // Cache the token + s.nekoToken = token + log.Info("successfully obtained Neko authentication token") + + return s.nekoToken, nil +} + +// clearNekoToken clears the cached token, forcing a new login on next request +func (s *ApiService) clearNekoToken() { + s.nekoTokenMu.Lock() + defer s.nekoTokenMu.Unlock() + s.nekoToken = "" +} + // setResolutionViaNeko delegates resolution change to Neko API -func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height int) error { +func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height, refreshRate int) error { log := logger.FromContext(ctx) + // Get authentication token + token, err := s.getNekoToken(ctx) + if err != nil { + return fmt.Errorf("failed to get Neko token: %w", err) + } + client := &http.Client{ Timeout: 5 * time.Second, } + // Use default refresh rate if not specified + if refreshRate <= 0 { + refreshRate = 60 + } + // Prepare request body for Neko's screen API screenConfig := map[string]interface{}{ "width": width, "height": height, - "rate": 60, // Default refresh rate + "rate": refreshRate, } body, err := json.Marshal(screenConfig) @@ -411,13 +579,8 @@ func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height int } req.Header.Set("Content-Type", "application/json") - - // Add authentication - Neko requires admin credentials - adminPassword := os.Getenv("NEKO_ADMIN_PASSWORD") - if adminPassword == "" { - adminPassword = "admin" // Default from neko.yaml - } - req.SetBasicAuth("admin", adminPassword) + // Add Bearer token authentication + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // Execute request resp, err := client.Do(req) @@ -426,11 +589,39 @@ func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height int } defer resp.Body.Close() + // Handle 401 by clearing token and retrying once + if resp.StatusCode == http.StatusUnauthorized { + log.Warn("Neko API returned 401, clearing cached token and retrying") + s.clearNekoToken() + + // Get fresh token + token, err = s.getNekoToken(ctx) + if err != nil { + return fmt.Errorf("failed to get fresh Neko token: %w", err) + } + + // Retry the request with fresh token + req, err = http.NewRequestWithContext(ctx, "POST", + "http://localhost:8080/api/room/screen", + strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("failed to create retry request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to retry Neko API call: %w", err) + } + defer resp.Body.Close() + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("Neko API returned status %d: %s", resp.StatusCode, string(respBody)) } - log.Info("successfully changed resolution via Neko API", "width", width, "height", height) + log.Info("successfully changed resolution via Neko API", "width", width, "height", height, "refresh_rate", refreshRate) return nil } diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index c0a1eb52..8454adf8 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -49,6 +49,13 @@ const ( WRITE FileSystemEventType = "WRITE" ) +// Defines values for PatchDisplayRequestRefreshRate. +const ( + N25 PatchDisplayRequestRefreshRate = 25 + N30 PatchDisplayRequestRefreshRate = 30 + N60 PatchDisplayRequestRefreshRate = 60 +) + // Defines values for ProcessKillRequestSignal. const ( HUP ProcessKillRequestSignal = "HUP" @@ -142,6 +149,9 @@ type DisplayConfig struct { // LiveViewSessions Number of active Neko viewer sessions. LiveViewSessions *int `json:"live_view_sessions,omitempty"` + // RefreshRate Current display refresh rate in Hz (may be null if not detectable) + RefreshRate *int `json:"refresh_rate,omitempty"` + // ResizableNow True when no blockers are present for resizing ResizableNow *bool `json:"resizable_now,omitempty"` @@ -237,13 +247,22 @@ type PatchDisplayRequest struct { // Height Display height in pixels Height *int `json:"height,omitempty"` + // RefreshRate Display refresh rate in Hz. If omitted, uses the highest available rate for the resolution. + RefreshRate *PatchDisplayRequestRefreshRate `json:"refresh_rate,omitempty"` + // RequireIdle If true, refuse to resize when live view or recording/replay is active. RequireIdle *bool `json:"require_idle,omitempty"` + // RestartChromium If true, restart Chromium after resolution change to ensure it adapts to new size. Default is false for headful, true for headless. + RestartChromium *bool `json:"restart_chromium,omitempty"` + // Width Display width in pixels Width *int `json:"width,omitempty"` } +// PatchDisplayRequestRefreshRate Display refresh rate in Hz. If omitted, uses the highest available rate for the resolution. +type PatchDisplayRequestRefreshRate int + // ProcessExecRequest Request to execute a command synchronously. type ProcessExecRequest struct { // Args Command arguments. @@ -8571,85 +8590,87 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+w9624bN5evQsz2R7MryU7itqj/JbHTNXKFlSDftskK9MyRxM8z5JTkSFYCv/vikJw7", - "RzOS7TguFiiQREMe8twvPGS/BaFIUsGBaxUcfwskqFRwBeYfz2l0Dn9noPSplELiT6HgGrjGv9I0jVlI", - "NRP84N9KcPxNhUtIKP7tJwnz4Dj4j4MS/oH9qg4stOvr61EQgQolSxFIcIwLErdicD0KXgg+j1n4vVbP", - "l8Olz7gGyWn8nZbOlyNTkCuQxA0cBW+FfikyHn2nfbwVmpj1AvzmhiO0FzELL9+ITEHOH9xAFDGcSOP3", - "UqQgNUO5mdNYwShIKz99Cy4yre0O6wsakMR+JVoQhoSgoSZrppfBKACeJcHxX0EMcx2MAskWS/wzYVEU", - "QzAKLmh4GYyCuZBrKqPgyyjQmxSC40BpyfgCSRji1mf25+byHzYpEDEnZgyhofm5XDUSa/xnlgYOjHeB", - "pYij2SVslA+9iM0ZSIKfET8cS6IMpxK9BLtwMAqYhsTMb0F3P1Ap6Qb/zbNkZma55eY0i3Vw/LjFyiy5", - "AInIaZaAWVxCClTX1nXQkewLMBJ31cbiXyQUQkaMU22oVQAgqVDM0awNadOG9D/7QLoeBRL+zpiECJly", - "FSDokhHi4t9glfaFBKrhhEkItZCb/SQ1EZFHUN6ldjqJcugEB5KfRahpTCy7RgQmiwn57ZdfHk3IieWM", - "Ifxvv/wyCUZBSjWqeXAc/O9fh+Pfvnx7Ojq6/inwiFRK9bK9iWcXSsSZhsomcCCuEBrUG4scTP6zDbxB", - "TbOSj5gnEIOG91Qv96NjDwr5xiOzzO1v/BxCI2iL/XbPovbezyLg2qqzE12ZL1LBhDyL0yXlWQKShURI", - "stykS+BN/tPx12fjPw/Hv4+//NdPXmTbiDGVxnSDbootdsRnCcZytnB6kUkJXJPIwiZ2HGGcpOwKYuVV", - "bKZmBeZtkJ+WoJcgK8RhioR2nXhjjOwKSrgXQsRAeQEXt9ED1+x0KNCYrWC2YrCeKVCKCe4x06WttIDI", - "W7gUBCeBJPm0iZcWEhT7Si9imHGx9jgYmQFZL4ETLshFLMJLkIpQCSSVoJDyc4EoKfYVkfYhsGaRT5Oa", - "nDPDtjLOJ1NFXNEwgqAUXYDHITW0MB/oU8SXLIYzPhdt8EzNIia7WYy6ZQwEU4SW1m7ipU8iohm6uDa4", - "11RpNNNs7sIk4wonNl5IqA6Og4hqGJvZHivsdwWIljX+F0wr8jPa/BH5HERyfSXH+N/nAPX+czCW67Ec", - "43+fg0cT3wqc+vb9nCog+Cm3M3NcUkgvJQY7DfzsnafYV5hdbDR4NGPKvgIKlfk8IYdGXPNtMPAqRUNE", - "DI5ud7XFRrkcVHjoiN4lTtON0pCcrlwE3GaMMgNIuKR8AQRwoLG8O4sfnc8h1BANl8N9eVkstS9Td5MS", - "fyBsSErw26QS/744P3324TQYBZ/Oz8yfJ6evT81fzk/fPntz6gmHG8w3X0fdzvo1U9rwzYMjRryIW5ti", - "jFsFRpUGrnNBLILobblPYZU8sfVrseiQrWckFguz1obMpUisjJQJWFvIKia0YZXEgriPRMOV9nMJY3ZN", - "k9TjUlgCZvlyR2uqSCpFlIVWioaYtw5DXl3ax7A3YgU3yANvkislwrj44alSXyqjhYFps5BMKiGJFnul", - "MkMhDU5lkMz7x94RKD3ryyFAadw86lDuGvpC8FGgZNgHWIlMhjAYZoMkxQKjChY+Cr27PHe1ql7i1Df6", - "B3ATmr97RfJqV1t7xWUtu9Yyg3bNJkLlB0VUFoaglM8tNLATl15c3lMdLl14v6dedcT3J91xfcI4S9DO", - "Pzk69Ee2Zt8zFsXQT4w5MT8TCfNMga02oK+3wS8G4SaeJqKSGByUobyNuye7BMAnnYFvgdnTJ4fDwuD3", - "UiAHT68gHEr/+mbcLMQbriBEPaAkFElCeUTUhodLKbjIVLxpyxqVi3ot568v7dKkhUTlIkvQnU12MoRU", - "zaQQuraIH42M2+Db0sNU4QhOJalkKxbDApSfSVTNMgWeoKoJkiqil0wRHI2geBbHmD/lctWu31ncPTGL", - "ITTORalSS4jjguQofxn3utZw7YH1SchL9DNljPEzrcZYjxxEa+HdIoz7EOg3osBX3eLlYWfBs2+tgu0p", - "XzEpOMoEWVHJcCPGeSqwqWWF9BVqlJKP3l5keqYg9LhkeoWK5EQ6T6BQ1xSEgkdqCwO7fF7Ozi99aqgs", - "yrtpIU4yeXxV6QqGFXi0lTDKpPGFs0R1SRrinw9DGiQsjlmFEG0TCldMz0JvFulQJTiE4BA/BKUjkHJ2", - "8euRP7X49WgMHKdHxA4lF9l8bjWr7bx1hKweCExkuhvYFiP6isXxfkZ0yhacxlZ6rQ43pLfOMmWG14xa", - "8OH0/E2wHW41wXHDX529fh2MgrO3H4JR8N8f3/fnNW7tLUI8TemaV+kQx+/mwfFf27MTjyO6/tICuodq", - "nFVSJnqBvKVEITRMcbsonPrKoO+mhS0/O/FLrfs+8023J1xjqpCEEBFWVlU99qrIZLKMRX6ZplJDNKPa", - "nymZTMYGIVUv5KbtkCx18llTnakduZEX75SZbA1WJxfCNJuloQe/U6VZQjVE5MX7jyQzGWUKMgSu6aJq", - "ULipb/ZYpNPcEhE2r9FqSa2ZsuTqM/ejIIGkq5xU7hijQ+Q8SSBBd2t3X1SaOowh1VtMqflc1W6ZcW6r", - "qnb7frXuZmzE+H6G7IRqiuZmLZlNDhuixyMqMXxIM091KqKaDrLRUXWVSW9mVcD90ovzjVwvbscdiSgE", - "18YQR2jgXUJSVuPNAOKGT4KdYvmplkDLUuEubmh6SlK6iQVFMXV1esQo56DIdJppDDpjNodwE8au1Khu", - "ys2itFQKC2Lh9ebgr1S9rm+pVdNDVfAeYw8yDYUhtcCZIp/NxM9Bl8ri/j1ewBYJ7Oe8gGlIEC4zflnd", - "sA1FgjwWGqjE9vwPpP8AYs44U8thbqM8x8pndTmN3lTG+sP2z+q8epzWTq52cHLlbt2kPTfbMB7G+Vb3", - "6TMiUzC13PcgE2bPzPYrZiykyDyFz7ewJuaTq6dL8kctANn14MZzdP/r0dGj3U7qxZr7sl7cq/lk8tx8", - "vx879jukyL9eCmXce05bc4qoBbkAd9wR7XuKvuXQZYpC9FJ9ojq81T6AoknDODCE7iWMhDCTiq2gv3RR", - "HN44eKSYG28GVOY664yGAjfsJphLmoD0Bi/npXXJB2EUNE9RQFcgJYtAEWXbwhwFHiHHbGoeHD85rBS8", - "HnuP66Mt/Sye8LtiQuzR9y31NJhNn7gE+oxPbebcXXUo91HNul3C3UOdrQRJ6JU5TGRf4Yy/ed69A3Py", - "pNwR6JvnAzny+PDwsMaUgUXIqRbpTQVNyBAQTr++nCUJRIxqiDdEaZGaWh/mhQtJQ5hnMVHLTEdizSfk", - "w5IpktANRu0Y5TFuqptSZinG8isWgTDE8tcGd2mmsRqMG7qzThr8ibmwQDONLjB4BZJDTM4SugBFnr0/", - "C0bBCqSymz2cPJ4cGmufAqcpC46Dp5PDyVN3smlIb7L5TIM8sA2Hicjs0UQqLBuRT1b0I8wAi4bKwBoi", - "UPq5iDa31uPZ7ti8rts8dPu2gaXs+H1yeNjVo2mbI9EBYTgBEZLjyA73baMAe9DsIr4eBb8MmVdvwTX9", - "qFmSULkxFZ0ki6kpshs61xo4ibAh6lIojFotVwyAkkeJWEEfi4qjzjviUOso9WYMcueOiNn9MudNfhKa", - "VPflsmCVQohqH1WOT9UWjrn2JhdJhMs2m6qHZ3fEKd/53HBm3coW6s1/ntbtj2lkCit5P1hoRjrHeRN5", - "ODr8vX9evVX/NqTI4tOFDorGXB3YttdZcWpjxCTzmdt6a/Bd2Vx/A/IgUXm8LUi2eEb5SfM8i+PNveq4", - "xZRQwmFdHpoVfLG9sAP4Ypt175ov7V7mfU1tyRKLYnQzzTrqn1e/AXIbvLPUqDa0NfmGoVwPyzCA/uG5", - "ZTL+fwCjDD8KHok1jwWNULtmX5kJ9RegfamlziRXhJI/z97bXAb5Qxkvbp9Ydqk8BC+dc62HsMF/t/4J", - "k3+y1ITAmLlqkMocbw2+s0BluGQrIJRHJEfK9DXgvL8zMObAdlXmdYq6DIwqAtVb9/iyk3N2dC3hFzW0", - "C8ap2VlzgZZHRqrnOBY5jhGsKoEfolw6ZlVNCKG5oDmUC3lFwZvl+ZYT1LpEFS2ZQ2Wpt+v1RxCh3Yxe", - "2ZbaFiRjxio9rw9QZP4AXevazY+gW9wrxCZmShtHpDrlpmwe3s8IPUxJKbH2iEoZnyD9XNntgckKImgE", - "Q9lCU1s2TCdwV3ySt87eYdZ+G7GJyZLLeP4B8slgYPo5zfnGNmWWQKMiqvTq8jnQyMWUw1TZLJaHEgj/", - "R9FmEWrQ4/Lg80YxhDH9iN2tpX73JCzI3zIGNc8N5MKhwBr6WeVwq1O722eMd6Tn3YeZ+2p8BRTJbLXm", - "ATJyCtpzI6fCugNz7qmWLC04nKUYLlYrrQ2ljmOxRqLgMHPwxPjCLpFksWZpDM4huCqihEQ4G2BvfLXT", - "lI8GWB4edEuIXYBKfYDqOY6opnUhaXYOuIik6L6++dWLSquQC2iHXcbIDWq/Xamfdc6tnd1+v6LeQ+6B", - "oDzT9ix0GS459sODN3VW8ojgVoCFdILaUIc8d/erhANCyVeWWn2zDczavI7BtCqT91Zl3Xe1x6ccNn2/", - "NdXYVfSjag9Ajpm5V+eSZi2G6cFXls721YVi7nZ92FOw/2RpKdYVBv5jhNzKZ7WSU4poIe/r/ODGf85W", - "7TG5K2fuaWMZztPBW2i0OeJq3h7oj5z9nYGv96LUibUjx6Dj7EYrjOl/cf1fD13QLDLVShPSynY8qbqI", - "HXzLSX5taR6DbblpyptIS3FrZBsmg3Apg0sgCj5uSyL6cwZPC2jOKJGmD59RU9NEghhhBOdL25tMOrBN", - "s505oW3hfalO7bDvyKtmfqfhStvdehO7vsJe9dkCj75Op6eVTtgyqHVNxcEoWAKNDNbfgn+Np9PT8Qu7", - "t/EH723+NxAxajp/ESCCN621Fhz5uWnEHgVV6uR9ty1T52m8vX6IYmoI3aKyMSvUmd1CYjEq334c9gmH", - "DKlcnFRCH9qqYtxd9WLU2fs3LxpiO3tha89Y/Xp01LVN00Dasa2tHbRW+YZ4/BvWVfZMS/LbBw/ejZr8", - "Ej1nfnJfHirGYqEOSsL6a+1i4a5UdNjhhkDYVwC2Sm5uaPKXYbIU5Iop4W/x9y8zF3Es1jXJa9xTb/f9", - "NtkseLwh+TYJm+cvGDBF3Na2KGa3V9llnQru/tXKATN3NSS4N49WvJLS68pQsH5o7+XzDLhpIlYgcWmr", - "II7kB3Blb0n785jK3c276kPz3A79vm1o7RvaHiEor0tLN+YeO5VOtz/HUGewuRHby2FzC/duWVy7PXw/", - "PK7eNfZpur08/IPxlm5h7rfyWvL1wSWL415Gv8JBQ9KOyoXnbR6v5zbz8FhoL4ZWL+Z/Z5GqPNbjEaV3", - "rx7kOQiakuJlgdwrd0ucKi6KewOs+nXy7y10d2xKLFI+K+K+PMiGlsqNboteN+sjNsCtmFH/GHNTuz9/", - "Ty6scp3d99x89Xr5g83pSuNj79tvl0OR6b5UrySeyPTWnO+e7NENchfP4wC9WUzj2j+GGc17//9foruD", - "El1FqkWmGylZ+YhcWeb3W9fGe+B32rTeur557QxfX29IeQ34H9CunkpYMROA55c6q3dEW/xz3cSd9ihv", - "N66ycGultShwFldKy5O2Cfm0BE5EgkY/GtmTc3uXN1Og7CGcrSAV07uKnsZ8+UuefZdS+42cIdhBkh7d", - "uIescsXclqlrpqr4On7pnrcYP9v6zISYl6+AtN/GmJA/Miop1wCRe53g/OWLp0+f/j7ZXi2rbWVqzy73", - "2kn+tNOeG8GtPDl8sk1FGdokFsfm0UkpFhKUGpE0BqqAaLkhdEEZJzHVIOvkPgctN+Nnc+17M2KaLRb2", - "csCaMt18ao9cwFxIRFTLjVWCEolt990fogcobhjYa6TK6KJ96n+ARYmZ9QOdTeP54zC2M+wGMeigF6dr", - "T9G0O6ta+mr6n8W8MD/q9rqqaRxXwdbJZhSnp03jrt2o/7UNrxd9vE1F88dvHt69V0MBQokKJVTf85mQ", - "dzzemK6y0talIMnZCQkpR/smYcGUBgkRoQjC/t8OWlwW6TYmV96guDMee9652D1Qcm0T9/sOgRZp3f0Y", - "RP4vAAD///S81cFWbQAA", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index 9b399117..e785535b 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1139,10 +1139,17 @@ components: type: integer minimum: 240 description: Display height in pixels + refresh_rate: + type: integer + enum: [60, 30, 25] + description: Display refresh rate in Hz. If omitted, uses the highest available rate for the resolution. require_idle: type: boolean description: If true, refuse to resize when live view or recording/replay is active. default: true + restart_chromium: + type: boolean + description: If true, restart Chromium after resolution change to ensure it adapts to new size. Default is false for headful, true for headless. additionalProperties: false DisplayConfig: type: object @@ -1153,6 +1160,9 @@ components: height: type: integer description: Current display height in pixels + refresh_rate: + type: integer + description: Current display refresh rate in Hz (may be null if not detectable) live_view_sessions: type: integer description: Number of active Neko viewer sessions. From a214bcf89afe3df382a7d9a712b26c52e8ac06fc Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Mon, 6 Oct 2025 22:35:38 -0400 Subject: [PATCH 05/18] Fix run scripts --- images/chromium-headful/run-docker.sh | 18 +++++++++++++++++- images/chromium-headful/run-unikernel.sh | 21 ++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/images/chromium-headful/run-docker.sh b/images/chromium-headful/run-docker.sh index 62361096..803bc2cf 100755 --- a/images/chromium-headful/run-docker.sh +++ b/images/chromium-headful/run-docker.sh @@ -22,7 +22,23 @@ CHROMIUM_FLAGS="${CHROMIUM_FLAGS:-$CHROMIUM_FLAGS_DEFAULT}" rm -rf .tmp/chromium mkdir -p .tmp/chromium FLAGS_FILE="$(pwd)/.tmp/chromium/flags" -echo "$CHROMIUM_FLAGS" > "$FLAGS_FILE" + +# Convert space-separated flags to JSON array format +IFS=' ' read -ra FLAGS_ARRAY <<< "$CHROMIUM_FLAGS" +FLAGS_JSON='{"flags":[' +FIRST=true +for flag in "${FLAGS_ARRAY[@]}"; do + if [ -n "$flag" ]; then + if [ "$FIRST" = true ]; then + FLAGS_JSON+="\"$flag\"" + FIRST=false + else + FLAGS_JSON+=",\"$flag\"" + fi + fi +done +FLAGS_JSON+=']}' +echo "$FLAGS_JSON" > "$FLAGS_FILE" echo "flags file: $FLAGS_FILE" cat "$FLAGS_FILE" diff --git a/images/chromium-headful/run-unikernel.sh b/images/chromium-headful/run-unikernel.sh index 9dc9cfe5..2c8c8a14 100755 --- a/images/chromium-headful/run-unikernel.sh +++ b/images/chromium-headful/run-unikernel.sh @@ -28,7 +28,26 @@ CHROMIUM_FLAGS="${CHROMIUM_FLAGS:-$chromium_flags_default}" rm -rf .tmp/chromium mkdir -p .tmp/chromium FLAGS_DIR=".tmp/chromium" -echo "$CHROMIUM_FLAGS" > "$FLAGS_DIR/flags" + +# Convert space-separated flags to JSON array format +IFS=' ' read -ra FLAGS_ARRAY <<< "$CHROMIUM_FLAGS" +FLAGS_JSON='{"flags":[' +FIRST=true +for flag in "${FLAGS_ARRAY[@]}"; do + if [ -n "$flag" ]; then + if [ "$FIRST" = true ]; then + FLAGS_JSON+="\"$flag\"" + FIRST=false + else + FLAGS_JSON+=",\"$flag\"" + fi + fi +done +FLAGS_JSON+=']}' +echo "$FLAGS_JSON" > "$FLAGS_DIR/flags" + +echo "flags file: $FLAGS_DIR/flags" +cat "$FLAGS_DIR/flags" # Re-create the volume from scratch every run kraft cloud volume rm "$volume_name" || true From b12c7c7312b10b033442bdc37a7ed7f61e916bb9 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Tue, 7 Oct 2025 11:24:20 -0400 Subject: [PATCH 06/18] locahost to 127.0.0.1 --- server/cmd/api/api/display.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index bbaf2a56..925a21db 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -326,7 +326,7 @@ func (s *ApiService) getActiveNekoSessions(ctx context.Context) int { } // Query Neko sessions API - req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:8080/api/sessions", nil) + req, err := http.NewRequestWithContext(ctx, "GET", "http://127.0.0.1:8080/api/sessions", nil) if err != nil { log.Debug("failed to create Neko API request", "error", err) return 0 @@ -493,7 +493,7 @@ func (s *ApiService) getNekoToken(ctx context.Context) (string, error) { // Call login endpoint req, err := http.NewRequestWithContext(ctx, "POST", - "http://localhost:8080/api/login", + "http://127.0.0.1:8080/api/login", strings.NewReader(string(loginBody))) if err != nil { return "", fmt.Errorf("failed to create login request: %w", err) @@ -572,7 +572,7 @@ func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height, re // Create request req, err := http.NewRequestWithContext(ctx, "POST", - "http://localhost:8080/api/room/screen", + "http://127.0.0.1:8080/api/room/screen", strings.NewReader(string(body))) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -602,7 +602,7 @@ func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height, re // Retry the request with fresh token req, err = http.NewRequestWithContext(ctx, "POST", - "http://localhost:8080/api/room/screen", + "http://127.0.0.1:8080/api/room/screen", strings.NewReader(string(body))) if err != nil { return fmt.Errorf("failed to create retry request: %w", err) From 7d491fe86e812b374ba22cac8f80e9af243d277d Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Tue, 7 Oct 2025 15:10:04 -0400 Subject: [PATCH 07/18] moar --- server/cmd/api/api/display.go | 57 ++++++++++++++--------------------- 1 file changed, 23 insertions(+), 34 deletions(-) diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index 925a21db..77e4d013 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -113,8 +113,9 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ // Return success with the new dimensions return oapi.PatchDisplay200JSONResponse{ - Width: &width, - Height: &height, + Width: &width, + Height: &height, + RefreshRate: &refreshRate, }, nil } @@ -140,25 +141,31 @@ func (s *ApiService) detectDisplayMode(ctx context.Context) string { return "xorg" } -// setResolutionXorgViaNeko changes resolution for Xorg using Neko API -func (s *ApiService) setResolutionXorgViaNeko(ctx context.Context, width, height, refreshRate int, restartChrome bool) error { +// restartChromium restarts the Chromium browser via supervisorctl +func (s *ApiService) restartChromium(ctx context.Context) { log := logger.FromContext(ctx) + log.Info("restarting chromium after resolution change") + + restartCmd := []string{"-lc", "supervisorctl restart chromium"} + restartReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartCmd} + + if restartResp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartReq}); err != nil { + log.Error("failed to restart chromium", "error", err) + } else if execResp, ok := restartResp.(oapi.ProcessExec200JSONResponse); ok { + if execResp.ExitCode != nil && *execResp.ExitCode != 0 { + log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) + } + } +} +// setResolutionXorgViaNeko changes resolution for Xorg using Neko API +func (s *ApiService) setResolutionXorgViaNeko(ctx context.Context, width, height, refreshRate int, restartChrome bool) error { if err := s.setResolutionViaNeko(ctx, width, height, refreshRate); err != nil { return fmt.Errorf("failed to change resolution via Neko API: %w", err) } if restartChrome { - log.Info("restarting chromium after resolution change") - restartCmd := []string{"-lc", "supervisorctl restart chromium"} - restartReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartCmd} - if restartResp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartReq}); err != nil { - log.Error("failed to restart chromium", "error", err) - } else if execResp, ok := restartResp.(oapi.ProcessExec200JSONResponse); ok { - if execResp.ExitCode != nil && *execResp.ExitCode != 0 { - log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) - } - } + s.restartChromium(ctx) } return nil @@ -204,16 +211,7 @@ func (s *ApiService) setResolutionXorgViaXrandr(ctx context.Context, width, heig log.Info("resolution updated via xrandr", "display", display, "width", width, "height", height) if restartChrome { - log.Info("restarting chromium after resolution change") - restartCmd := []string{"-lc", "supervisorctl restart chromium"} - restartReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartCmd} - if restartResp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartReq}); err != nil { - log.Error("failed to restart chromium", "error", err) - } else if execResp, ok := restartResp.(oapi.ProcessExec200JSONResponse); ok { - if execResp.ExitCode != nil && *execResp.ExitCode != 0 { - log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) - } - } + s.restartChromium(ctx) } return nil case oapi.ProcessExec400JSONResponse: @@ -283,16 +281,7 @@ func (s *ApiService) setResolutionXvfb(ctx context.Context, width, height int, r s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &waitReq}) if restartChrome { - log.Info("restarting chromium after Xvfb restart") - restartChromeCmd := []string{"-lc", "supervisorctl restart chromium"} - restartChromeReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartChromeCmd} - if chromeResp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartChromeReq}); err != nil { - log.Error("failed to restart chromium", "error", err) - } else if execResp, ok := chromeResp.(oapi.ProcessExec200JSONResponse); ok { - if execResp.ExitCode != nil && *execResp.ExitCode != 0 { - log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) - } - } + s.restartChromium(ctx) } log.Info("Xvfb resolution updated", "width", width, "height", height) From fe4b7f3738239de5ef3c398879051087c91cad2e Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Wed, 8 Oct 2025 10:04:59 -0400 Subject: [PATCH 08/18] avoid race condition --- server/cmd/api/api/display.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index 77e4d013..32412059 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -518,7 +518,7 @@ func (s *ApiService) getNekoToken(ctx context.Context) (string, error) { s.nekoToken = token log.Info("successfully obtained Neko authentication token") - return s.nekoToken, nil + return token, nil } // clearNekoToken clears the cached token, forcing a new login on next request From 209ffa9e07aa5f421fb240e6fa1f0f71162dda0c Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Wed, 8 Oct 2025 15:38:22 -0400 Subject: [PATCH 09/18] pr comments --- server/cmd/api/api/api.go | 19 +- server/cmd/api/api/display.go | 305 ++++++-------------------------- server/go.mod | 13 +- server/go.sum | 21 ++- server/lib/nekoclient/client.go | 187 ++++++++++++++++++++ server/lib/oapi/oapi.go | 185 +++++++++---------- server/openapi.yaml | 12 -- 7 files changed, 367 insertions(+), 375 deletions(-) create mode 100644 server/lib/nekoclient/client.go diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index be939cd5..975d214c 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -10,6 +10,7 @@ import ( "github.com/onkernel/kernel-images/server/lib/devtoolsproxy" "github.com/onkernel/kernel-images/server/lib/logger" + "github.com/onkernel/kernel-images/server/lib/nekoclient" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" "github.com/onkernel/kernel-images/server/lib/scaletozero" @@ -29,13 +30,12 @@ type ApiService struct { procMu sync.RWMutex procs map[string]*processHandle - // Neko authentication - nekoTokenMu sync.RWMutex - nekoToken string + // Neko authenticated client + nekoAuthClient *nekoclient.AuthClient // DevTools upstream manager (Chromium supervisord log tailer) upstreamMgr *devtoolsproxy.UpstreamManager - stz scaletozero.Controller + stz scaletozero.Controller } var _ oapi.StrictServerInterface = (*ApiService)(nil) @@ -50,6 +50,16 @@ func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFa return nil, fmt.Errorf("upstreamMgr cannot be nil") } + // Initialize Neko authenticated client + adminPassword := os.Getenv("NEKO_ADMIN_PASSWORD") + if adminPassword == "" { + adminPassword = "admin" // Default from neko.yaml + } + nekoAuthClient, err := nekoclient.NewAuthClient("http://127.0.0.1:8080", "admin", adminPassword) + if err != nil { + return nil, fmt.Errorf("failed to create neko auth client: %w", err) + } + return &ApiService{ recordManager: recordManager, factory: factory, @@ -58,6 +68,7 @@ func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFa procs: make(map[string]*processHandle), upstreamMgr: upstreamMgr, stz: stz, + nekoAuthClient: nekoAuthClient, }, nil } diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index 32412059..5af2ec79 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -3,16 +3,14 @@ package api import ( "context" "encoding/base64" - "encoding/json" "fmt" - "io" - "net/http" "os" "os/exec" "strconv" "strings" "time" + nekooapi "github.com/m1k1o/neko/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" ) @@ -23,6 +21,10 @@ import ( // or Xvfb (headless) and uses the appropriate method to change resolution. func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequestObject) (oapi.PatchDisplayResponseObject, error) { log := logger.FromContext(ctx) + + s.stz.Disable(ctx) + defer s.stz.Enable(ctx) + if req.Body == nil { return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "missing request body"}}, nil } @@ -52,7 +54,7 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid width/height"}}, nil } - log.Info("resolution change requested", "width", width, "height", height, "refresh_rate", refreshRate) + log.Info(fmt.Sprintf("resolution change requested from %dx%d@%d to %dx%d@%d", currentWidth, currentHeight, currentRefreshRate, width, height, refreshRate)) // Parse requireIdle flag (default true) requireIdle := true @@ -64,10 +66,9 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ if requireIdle { live := s.getActiveNekoSessions(ctx) isRecording := s.anyRecordingActive(ctx) - isReplaying := false // replay not currently implemented - resizableNow := (live == 0) && !isRecording && !isReplaying + resizableNow := (live == 0) && !isRecording - log.Info("checking if resize is safe", "live_sessions", live, "is_recording", isRecording, "is_replaying", isReplaying, "resizable", resizableNow) + log.Info("checking if resize is safe", "live_sessions", live, "is_recording", isRecording, "resizable", resizableNow) if !resizableNow { return oapi.PatchDisplay409JSONResponse{ @@ -92,11 +93,14 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ if displayMode == "xorg" { if s.isNekoEnabled() { log.Info("using Neko API for Xorg resolution change") - err = s.setResolutionXorgViaNeko(ctx, width, height, refreshRate, restartChrome) + err = s.setResolutionViaNeko(ctx, width, height, refreshRate) } else { log.Info("using xrandr for Xorg resolution change (Neko disabled)") err = s.setResolutionXorgViaXrandr(ctx, width, height, refreshRate, restartChrome) } + if err == nil && restartChrome { + s.restartChromium(ctx) + } } else { log.Info("using Xvfb restart for resolution change") err = s.setResolutionXvfb(ctx, width, height, restartChrome) @@ -141,36 +145,42 @@ func (s *ApiService) detectDisplayMode(ctx context.Context) string { return "xorg" } -// restartChromium restarts the Chromium browser via supervisorctl +// restartChromium restarts the Chromium browser via supervisorctl and waits for DevTools to be ready func (s *ApiService) restartChromium(ctx context.Context) { log := logger.FromContext(ctx) - log.Info("restarting chromium after resolution change") - - restartCmd := []string{"-lc", "supervisorctl restart chromium"} - restartReq := oapi.ProcessExecRequest{Command: "bash", Args: &restartCmd} - - if restartResp, err := s.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: &restartReq}); err != nil { - log.Error("failed to restart chromium", "error", err) - } else if execResp, ok := restartResp.(oapi.ProcessExec200JSONResponse); ok { - if execResp.ExitCode != nil && *execResp.ExitCode != 0 { - log.Error("chromium restart failed", "exit_code", *execResp.ExitCode) + start := time.Now() + + // Begin listening for devtools URL updates, since we are about to restart Chromium + updates, cancelSub := s.upstreamMgr.Subscribe() + defer cancelSub() + + // Run supervisorctl restart with a new context to let it run beyond the lifetime of the http request. + // This lets us return as soon as the DevTools URL is updated. + errCh := make(chan error, 1) + log.Info("restarting chromium via supervisorctl") + go func() { + cmdCtx, cancelCmd := context.WithTimeout(context.WithoutCancel(ctx), 1*time.Minute) + defer cancelCmd() + out, err := exec.CommandContext(cmdCtx, "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "restart", "chromium").CombinedOutput() + if err != nil { + log.Error("failed to restart chromium", "error", err, "out", string(out)) + errCh <- fmt.Errorf("supervisorctl restart failed: %w", err) } + }() + + // Wait for either a new upstream, a restart error, or timeout + timeout := time.NewTimer(15 * time.Second) + defer timeout.Stop() + select { + case <-updates: + log.Info("chromium devtools ready after resolution change", "elapsed", time.Since(start).String()) + case err := <-errCh: + log.Error("chromium restart failed", "error", err, "elapsed", time.Since(start).String()) + case <-timeout.C: + log.Warn("chromium devtools not ready in time after resolution change", "elapsed", time.Since(start).String()) } } -// setResolutionXorgViaNeko changes resolution for Xorg using Neko API -func (s *ApiService) setResolutionXorgViaNeko(ctx context.Context, width, height, refreshRate int, restartChrome bool) error { - if err := s.setResolutionViaNeko(ctx, width, height, refreshRate); err != nil { - return fmt.Errorf("failed to change resolution via Neko API: %w", err) - } - - if restartChrome { - s.restartChromium(ctx) - } - - return nil -} - // setResolutionXorgViaXrandr changes resolution for Xorg using xrandr (fallback when Neko is disabled) func (s *ApiService) setResolutionXorgViaXrandr(ctx context.Context, width, height, refreshRate int, restartChrome bool) error { log := logger.FromContext(ctx) @@ -302,64 +312,22 @@ func (s *ApiService) anyRecordingActive(ctx context.Context) bool { func (s *ApiService) getActiveNekoSessions(ctx context.Context) int { log := logger.FromContext(ctx) - // Get authentication token - token, err := s.getNekoToken(ctx) - if err != nil { - log.Debug("failed to get Neko token", "error", err) - return 0 - } - - // Create HTTP client with short timeout - client := &http.Client{ - Timeout: 500 * time.Millisecond, - } - - // Query Neko sessions API - req, err := http.NewRequestWithContext(ctx, "GET", "http://127.0.0.1:8080/api/sessions", nil) - if err != nil { - log.Debug("failed to create Neko API request", "error", err) - return 0 - } - - // Add Bearer token authentication - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - - resp, err := client.Do(req) + // Query sessions using authenticated client + sessions, err := s.nekoAuthClient.SessionsGet(ctx) if err != nil { - log.Debug("failed to query Neko API", "error", err) + log.Debug("failed to query Neko sessions", "error", err) return 0 } - defer resp.Body.Close() - // Check response status - if resp.StatusCode == http.StatusUnauthorized { - log.Warn("Neko API returned 401, clearing cached token") - s.clearNekoToken() - return 0 - } - if resp.StatusCode != http.StatusOK { - log.Warn("Neko API returned non-OK status", "status", resp.StatusCode) - return 0 - } - - // Parse response - var sessions []map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { - log.Error("failed to parse Neko API response", "error", err) - return 0 - } - - // Debug: log each session to understand what we're counting + // Count active sessions (connected and watching) live := 0 for i, session := range sessions { log.Info("neko session details", "index", i, "session", session) - if stRaw, ok := session["state"]; ok { - if st, ok := stRaw.(map[string]interface{}); ok { - connected, _ := st["is_connected"].(bool) - watching, _ := st["is_watching"].(bool) - if connected && watching { - live++ - } + if session.State != nil { + connected := session.State.IsConnected != nil && *session.State.IsConnected + watching := session.State.IsWatching != nil && *session.State.IsWatching + if connected && watching { + live++ } } } @@ -435,180 +403,25 @@ func (s *ApiService) isNekoEnabled() bool { return os.Getenv("ENABLE_WEBRTC") == "true" } -// getNekoToken obtains a bearer token from Neko API for authentication. -// It caches the token and reuses it for subsequent requests. -func (s *ApiService) getNekoToken(ctx context.Context) (string, error) { - log := logger.FromContext(ctx) - - // Check if we have a cached token - s.nekoTokenMu.RLock() - cachedToken := s.nekoToken - s.nekoTokenMu.RUnlock() - - if cachedToken != "" { - return cachedToken, nil - } - - // Need to obtain a new token - s.nekoTokenMu.Lock() - defer s.nekoTokenMu.Unlock() - - // Double-check in case another goroutine just obtained the token - if s.nekoToken != "" { - return s.nekoToken, nil - } - - // Get admin credentials - adminPassword := os.Getenv("NEKO_ADMIN_PASSWORD") - if adminPassword == "" { - adminPassword = "admin" // Default from neko.yaml - } - - // Prepare login request - loginReq := map[string]string{ - "username": "admin", - "password": adminPassword, - } - - loginBody, err := json.Marshal(loginReq) - if err != nil { - return "", fmt.Errorf("failed to marshal login request: %w", err) - } - - // Create HTTP client - client := &http.Client{ - Timeout: 5 * time.Second, - } - - // Call login endpoint - req, err := http.NewRequestWithContext(ctx, "POST", - "http://127.0.0.1:8080/api/login", - strings.NewReader(string(loginBody))) - if err != nil { - return "", fmt.Errorf("failed to create login request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to call login API: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("login API returned status %d: %s", resp.StatusCode, string(respBody)) - } - - // Parse response to get token - var loginResp map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { - return "", fmt.Errorf("failed to parse login response: %w", err) - } - - log.Debug("neko login response", "response", loginResp) - - token, ok := loginResp["token"].(string) - if !ok || token == "" { - return "", fmt.Errorf("login response did not contain a token") - } - - // Cache the token - s.nekoToken = token - log.Info("successfully obtained Neko authentication token") - - return token, nil -} - -// clearNekoToken clears the cached token, forcing a new login on next request -func (s *ApiService) clearNekoToken() { - s.nekoTokenMu.Lock() - defer s.nekoTokenMu.Unlock() - s.nekoToken = "" -} - // setResolutionViaNeko delegates resolution change to Neko API func (s *ApiService) setResolutionViaNeko(ctx context.Context, width, height, refreshRate int) error { log := logger.FromContext(ctx) - // Get authentication token - token, err := s.getNekoToken(ctx) - if err != nil { - return fmt.Errorf("failed to get Neko token: %w", err) - } - - client := &http.Client{ - Timeout: 5 * time.Second, - } - // Use default refresh rate if not specified if refreshRate <= 0 { refreshRate = 60 } - // Prepare request body for Neko's screen API - screenConfig := map[string]interface{}{ - "width": width, - "height": height, - "rate": refreshRate, - } - - body, err := json.Marshal(screenConfig) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - // Create request - req, err := http.NewRequestWithContext(ctx, "POST", - "http://127.0.0.1:8080/api/room/screen", - strings.NewReader(string(body))) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - // Add Bearer token authentication - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - - // Execute request - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to call Neko API: %w", err) - } - defer resp.Body.Close() - - // Handle 401 by clearing token and retrying once - if resp.StatusCode == http.StatusUnauthorized { - log.Warn("Neko API returned 401, clearing cached token and retrying") - s.clearNekoToken() - - // Get fresh token - token, err = s.getNekoToken(ctx) - if err != nil { - return fmt.Errorf("failed to get fresh Neko token: %w", err) - } - - // Retry the request with fresh token - req, err = http.NewRequestWithContext(ctx, "POST", - "http://127.0.0.1:8080/api/room/screen", - strings.NewReader(string(body))) - if err != nil { - return fmt.Errorf("failed to create retry request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - - resp, err = client.Do(req) - if err != nil { - return fmt.Errorf("failed to retry Neko API call: %w", err) - } - defer resp.Body.Close() + // Prepare screen configuration + screenConfig := nekooapi.ScreenConfiguration{ + Width: &width, + Height: &height, + Rate: &refreshRate, } - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("Neko API returned status %d: %s", resp.StatusCode, string(respBody)) + // Change screen configuration using authenticated client + if err := s.nekoAuthClient.ScreenConfigurationChange(ctx, screenConfig); err != nil { + return fmt.Errorf("failed to change screen configuration: %w", err) } log.Info("successfully changed resolution via Neko API", "width", width, "height", height, "refresh_rate", refreshRate) diff --git a/server/go.mod b/server/go.mod index 15c3a663..f86d97a5 100644 --- a/server/go.mod +++ b/server/go.mod @@ -9,17 +9,18 @@ require ( github.com/ghodss/yaml v1.0.0 github.com/glebarez/sqlite v1.11.0 github.com/go-chi/chi/v5 v5.2.1 - github.com/google/uuid v1.5.0 + github.com/google/uuid v1.6.0 github.com/kelseyhightower/envconfig v1.4.0 + github.com/m1k1o/neko/server v0.0.0-20251008180819-e622f6212614 github.com/nrednav/cuid2 v1.1.0 - github.com/oapi-codegen/runtime v1.1.1 - github.com/stretchr/testify v1.9.0 + github.com/oapi-codegen/runtime v1.1.2 + github.com/stretchr/testify v1.10.0 golang.org/x/sync v0.15.0 ) require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -33,7 +34,7 @@ require ( github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/sys v0.34.0 // indirect @@ -45,3 +46,5 @@ require ( modernc.org/memory v1.5.0 // indirect modernc.org/sqlite v1.23.1 // indirect ) + +replace github.com/m1k1o/neko/server => github.com/onkernel/neko/server v0.0.0-20251008180819-e622f6212614 diff --git a/server/go.sum b/server/go.sum index 0b8d7092..af5a7dbc 100644 --- a/server/go.sum +++ b/server/go.sum @@ -5,8 +5,8 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -29,8 +29,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -52,16 +52,19 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/nrednav/cuid2 v1.1.0 h1:Y2P9Fo1Iz7lKuwcn+fS0mbxkNvEqoNLUtm0+moHCnYc= github.com/nrednav/cuid2 v1.1.0/go.mod h1:jBjkJAI+QLM4EUGvtwGDHC1cP1QQrRNfLo/A7qJFDhA= -github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= -github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onkernel/neko/server v0.0.0-20251008180819-e622f6212614 h1:iC/Y89xOyvp21MQOBwSjQpsccuBTSHj5NefXzWJkAsQ= +github.com/onkernel/neko/server v0.0.0-20251008180819-e622f6212614/go.mod h1:0+zactiySvtKwfe5JFjyNrSuQLA+EEPZl5bcfcZf1RM= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -70,8 +73,8 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= diff --git a/server/lib/nekoclient/client.go b/server/lib/nekoclient/client.go new file mode 100644 index 00000000..9dbd960e --- /dev/null +++ b/server/lib/nekoclient/client.go @@ -0,0 +1,187 @@ +package nekoclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + + nekooapi "github.com/m1k1o/neko/server/lib/oapi" +) + +// AuthClient wraps the Neko OpenAPI client and handles authentication automatically. +// It manages token caching and refresh on 401 responses. +type AuthClient struct { + client *nekooapi.Client + tokenMu sync.Mutex + token string + username string + password string +} + +// NewAuthClient creates a new authenticated Neko client. +func NewAuthClient(baseURL, username, password string) (*AuthClient, error) { + client, err := nekooapi.NewClient(baseURL) + if err != nil { + return nil, fmt.Errorf("failed to create neko client: %w", err) + } + + return &AuthClient{ + client: client, + username: username, + password: password, + }, nil +} + +// ensureToken ensures we have a valid token, logging in if necessary. +// Must be called with tokenMu held. +func (c *AuthClient) ensureToken(ctx context.Context) error { + // Check if we already have a token + if c.token != "" { + return nil + } + + // Login to get a new token + loginReq := nekooapi.SessionLoginRequest{ + Username: &c.username, + Password: &c.password, + } + + resp, err := c.client.Login(ctx, loginReq) + if err != nil { + return fmt.Errorf("failed to call login API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("login API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var loginResp nekooapi.SessionLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return fmt.Errorf("failed to parse login response: %w", err) + } + + if loginResp.Token == nil || *loginResp.Token == "" { + return fmt.Errorf("login response did not contain a token") + } + + c.token = *loginResp.Token + return nil +} + +// clearToken clears the cached token, forcing a new login on next request. +// Must be called with tokenMu held. +func (c *AuthClient) clearToken() { + c.token = "" +} + +// SessionsGet retrieves all active sessions from Neko API. +func (c *AuthClient) SessionsGet(ctx context.Context) ([]nekooapi.SessionData, error) { + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + + // Ensure we have a token + if err := c.ensureToken(ctx); err != nil { + return nil, err + } + + // Create request editor to add Bearer token + addAuth := func(ctx context.Context, req *http.Request) error { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) + return nil + } + + // Make the request + resp, err := c.client.SessionsGet(ctx, addAuth) + if err != nil { + return nil, fmt.Errorf("failed to query sessions: %w", err) + } + defer resp.Body.Close() + + // Handle 401 by clearing token and retrying once + if resp.StatusCode == http.StatusUnauthorized { + c.clearToken() + if err := c.ensureToken(ctx); err != nil { + return nil, err + } + + // Retry with fresh token + addAuthRetry := func(ctx context.Context, req *http.Request) error { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) + return nil + } + + resp, err = c.client.SessionsGet(ctx, addAuthRetry) + if err != nil { + return nil, fmt.Errorf("failed to retry sessions query: %w", err) + } + defer resp.Body.Close() + } + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("sessions API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var sessions []nekooapi.SessionData + if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { + return nil, fmt.Errorf("failed to parse sessions response: %w", err) + } + + return sessions, nil +} + +// ScreenConfigurationChange changes the screen resolution via Neko API. +func (c *AuthClient) ScreenConfigurationChange(ctx context.Context, config nekooapi.ScreenConfiguration) error { + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + + // Ensure we have a token + if err := c.ensureToken(ctx); err != nil { + return err + } + + // Create request editor to add Bearer token + addAuth := func(ctx context.Context, req *http.Request) error { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) + return nil + } + + // Make the request + resp, err := c.client.ScreenConfigurationChange(ctx, config, addAuth) + if err != nil { + return fmt.Errorf("failed to change screen configuration: %w", err) + } + defer resp.Body.Close() + + // Handle 401 by clearing token and retrying once + if resp.StatusCode == http.StatusUnauthorized { + c.clearToken() + if err := c.ensureToken(ctx); err != nil { + return err + } + + // Retry with fresh token + addAuthRetry := func(ctx context.Context, req *http.Request) error { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) + return nil + } + + resp, err = c.client.ScreenConfigurationChange(ctx, config, addAuthRetry) + if err != nil { + return fmt.Errorf("failed to retry screen configuration change: %w", err) + } + defer resp.Body.Close() + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("screen configuration API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 44f62c0d..81d01411 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -140,21 +140,9 @@ type DisplayConfig struct { // Height Current display height in pixels Height *int `json:"height,omitempty"` - // IsRecording Whether recording is currently active - IsRecording *bool `json:"is_recording,omitempty"` - - // IsReplaying Whether replay is currently active - IsReplaying *bool `json:"is_replaying,omitempty"` - - // LiveViewSessions Number of active Neko viewer sessions. - LiveViewSessions *int `json:"live_view_sessions,omitempty"` - // RefreshRate Current display refresh rate in Hz (may be null if not detectable) RefreshRate *int `json:"refresh_rate,omitempty"` - // ResizableNow True when no blockers are present for resizing - ResizableNow *bool `json:"resizable_now,omitempty"` - // Width Current display width in pixels Width *int `json:"width,omitempty"` } @@ -8811,93 +8799,92 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "AAD//1BnF8XFcgAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index 0d06a41c..998ec40f 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1202,18 +1202,6 @@ components: refresh_rate: type: integer description: Current display refresh rate in Hz (may be null if not detectable) - live_view_sessions: - type: integer - description: Number of active Neko viewer sessions. - is_recording: - type: boolean - description: Whether recording is currently active - is_replaying: - type: boolean - description: Whether replay is currently active - resizable_now: - type: boolean - description: True when no blockers are present for resizing additionalProperties: false LogEvent: type: object From 8ff23007f073713121d2811780c5c15922d5317c Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Wed, 8 Oct 2025 16:03:38 -0400 Subject: [PATCH 10/18] add e2e tests --- server/e2e/e2e_chromium_test.go | 191 +++++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 1 deletion(-) diff --git a/server/e2e/e2e_chromium_test.go b/server/e2e/e2e_chromium_test.go index b655d5f6..7f1e471f 100644 --- a/server/e2e/e2e_chromium_test.go +++ b/server/e2e/e2e_chromium_test.go @@ -18,6 +18,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" "time" @@ -99,6 +100,141 @@ func TestChromiumHeadlessPersistence(t *testing.T) { runChromiumUserDataSavingFlow(t, headlessImage, containerName) } +func TestDisplayResolutionChange(t *testing.T) { + image := headlessImage + name := containerName + "-display" + + logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) + baseCtx := logctx.AddToContext(context.Background(), logger) + + if _, err := exec.LookPath("docker"); err != nil { + t.Fatalf("docker not available: %v", err) + } + + // Clean slate + _ = stopContainer(baseCtx, name) + + // Start with default resolution + env := map[string]string{ + "WIDTH": "1024", + "HEIGHT": "768", + } + + // Start container + _, exitCh, err := runContainer(baseCtx, image, name, env) + if err != nil { + t.Fatalf("failed to start container: %v", err) + } + defer stopContainer(baseCtx, name) + + ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + defer cancel() + + logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml") + if err := waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh); err != nil { + _ = dumpContainerDiagnostics(ctx, name) + t.Fatalf("api not ready: %v", err) + } + + client, err := apiClient() + if err != nil { + t.Fatalf("failed to create API client: %v", err) + } + + // Get initial Xvfb resolution + logger.Info("[test]", "action", "getting initial Xvfb resolution") + initialWidth, initialHeight, err := getXvfbResolution(ctx) + if err != nil { + t.Fatalf("failed to get initial Xvfb resolution: %v", err) + } + logger.Info("[test]", "initial_resolution", fmt.Sprintf("%dx%d", initialWidth, initialHeight)) + if initialWidth != 1024 || initialHeight != 768 { + t.Errorf("expected initial resolution 1024x768, got %dx%d", initialWidth, initialHeight) + } + + // Test first resolution change: 1920x1080 + logger.Info("[test]", "action", "changing resolution to 1920x1080") + width1 := 1920 + height1 := 1080 + req1 := instanceoapi.PatchDisplayJSONRequestBody{ + Width: &width1, + Height: &height1, + } + rsp1, err := client.PatchDisplayWithResponse(ctx, req1) + if err != nil { + t.Fatalf("PATCH /display request failed: %v", err) + } + if rsp1.StatusCode() != http.StatusOK { + t.Fatalf("unexpected status: %s body=%s", rsp1.Status(), string(rsp1.Body)) + } + if rsp1.JSON200 == nil { + t.Fatalf("expected JSON200 response, got nil") + } + if rsp1.JSON200.Width == nil || *rsp1.JSON200.Width != width1 { + t.Errorf("expected width %d in response, got %v", width1, rsp1.JSON200.Width) + } + if rsp1.JSON200.Height == nil || *rsp1.JSON200.Height != height1 { + t.Errorf("expected height %d in response, got %v", height1, rsp1.JSON200.Height) + } + + // Wait a bit for Xvfb to fully restart + logger.Info("[test]", "action", "waiting for Xvfb to stabilize") + time.Sleep(3 * time.Second) + + // Verify new resolution via ps aux + logger.Info("[test]", "action", "verifying new Xvfb resolution") + newWidth1, newHeight1, err := getXvfbResolution(ctx) + if err != nil { + t.Fatalf("failed to get new Xvfb resolution: %v", err) + } + logger.Info("[test]", "new_resolution", fmt.Sprintf("%dx%d", newWidth1, newHeight1)) + if newWidth1 != width1 || newHeight1 != height1 { + t.Errorf("expected Xvfb resolution %dx%d, got %dx%d", width1, height1, newWidth1, newHeight1) + } + + // Test second resolution change: 1280x720 + logger.Info("[test]", "action", "changing resolution to 1280x720") + width2 := 1280 + height2 := 720 + req2 := instanceoapi.PatchDisplayJSONRequestBody{ + Width: &width2, + Height: &height2, + } + rsp2, err := client.PatchDisplayWithResponse(ctx, req2) + if err != nil { + t.Fatalf("PATCH /display request failed: %v", err) + } + if rsp2.StatusCode() != http.StatusOK { + t.Fatalf("unexpected status: %s body=%s", rsp2.Status(), string(rsp2.Body)) + } + if rsp2.JSON200 == nil { + t.Fatalf("expected JSON200 response, got nil") + } + if rsp2.JSON200.Width == nil || *rsp2.JSON200.Width != width2 { + t.Errorf("expected width %d in response, got %v", width2, rsp2.JSON200.Width) + } + if rsp2.JSON200.Height == nil || *rsp2.JSON200.Height != height2 { + t.Errorf("expected height %d in response, got %v", height2, rsp2.JSON200.Height) + } + + // Wait a bit for Xvfb to fully restart + logger.Info("[test]", "action", "waiting for Xvfb to stabilize") + time.Sleep(3 * time.Second) + + // Verify second resolution change via ps aux + logger.Info("[test]", "action", "verifying second Xvfb resolution") + newWidth2, newHeight2, err := getXvfbResolution(ctx) + if err != nil { + t.Fatalf("failed to get second Xvfb resolution: %v", err) + } + logger.Info("[test]", "final_resolution", fmt.Sprintf("%dx%d", newWidth2, newHeight2)) + if newWidth2 != width2 || newHeight2 != height2 { + t.Errorf("expected Xvfb resolution %dx%d, got %dx%d", width2, height2, newWidth2, newHeight2) + } + + logger.Info("[test]", "result", "all resolution changes verified successfully") +} + func TestExtensionUploadAndActivation(t *testing.T) { ensurePlaywrightDeps(t) image := headlessImage @@ -414,7 +550,8 @@ func runContainer(ctx context.Context, image, name string, env map[string]string "run", "--name", name, "--privileged", - "--network=host", + "-p", "10001:10001", // API server + "-p", "9222:9222", // DevTools proxy "--tmpfs", "/dev/shm:size=2g", } for k, v := range env { @@ -1290,3 +1427,55 @@ func verifyCookieInContainerDB(ctx context.Context, cookieName string) error { logger.Info("[container-cookie-verify]", "action", "cookie verified successfully", "cookieName", cookieName, "output", stdout) return nil } + +// getXvfbResolution extracts the Xvfb resolution from the ps aux output +// It looks for the Xvfb command line which contains "-screen 0 WIDTHxHEIGHTx24" +func getXvfbResolution(ctx context.Context) (width, height int, err error) { + logger := logctx.FromContext(ctx) + + // Get ps aux output + stdout, err := execCombinedOutput(ctx, "ps", []string{"aux"}) + if err != nil { + return 0, 0, fmt.Errorf("failed to execute ps aux: %w, output: %s", err, stdout) + } + + logger.Info("[xvfb-resolution]", "action", "parsing ps aux output") + + // Look for Xvfb line + // Expected format: "root ... Xvfb :1 -screen 0 1920x1080x24 ..." + lines := strings.Split(stdout, "\n") + for _, line := range lines { + if !strings.Contains(line, "Xvfb") { + continue + } + logger.Info("[xvfb-resolution]", "line", line) + + // Parse the screen parameter + // Look for pattern: "-screen 0 WIDTHxHEIGHTx24" + fields := strings.Fields(line) + for i, field := range fields { + if field == "-screen" && i+2 < len(fields) { + // Next field should be "0", and the one after should be the resolution + screenSpec := fields[i+2] + logger.Info("[xvfb-resolution]", "screen_spec", screenSpec) + + // Parse WIDTHxHEIGHTx24 + parts := strings.Split(screenSpec, "x") + if len(parts) >= 2 { + w, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, fmt.Errorf("failed to parse width from %q: %w", screenSpec, err) + } + h, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, fmt.Errorf("failed to parse height from %q: %w", screenSpec, err) + } + logger.Info("[xvfb-resolution]", "parsed", fmt.Sprintf("%dx%d", w, h)) + return w, h, nil + } + } + } + } + + return 0, 0, fmt.Errorf("Xvfb process not found in ps aux output") +} From 792beeeb2acdf922e7dd394b0a8b33e752011671 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Wed, 8 Oct 2025 16:10:26 -0400 Subject: [PATCH 11/18] Change deps version --- server/e2e/e2e_chromium_test.go | 2 +- server/go.mod | 4 ++-- server/go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/e2e/e2e_chromium_test.go b/server/e2e/e2e_chromium_test.go index 7f1e471f..4df3b061 100644 --- a/server/e2e/e2e_chromium_test.go +++ b/server/e2e/e2e_chromium_test.go @@ -551,7 +551,7 @@ func runContainer(ctx context.Context, image, name string, env map[string]string "--name", name, "--privileged", "-p", "10001:10001", // API server - "-p", "9222:9222", // DevTools proxy + "-p", "9222:9222", // DevTools proxy "--tmpfs", "/dev/shm:size=2g", } for k, v := range env { diff --git a/server/go.mod b/server/go.mod index f86d97a5..66ede4e5 100644 --- a/server/go.mod +++ b/server/go.mod @@ -11,7 +11,7 @@ require ( github.com/go-chi/chi/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/kelseyhightower/envconfig v1.4.0 - github.com/m1k1o/neko/server v0.0.0-20251008180819-e622f6212614 + github.com/m1k1o/neko/server v0.0.0-20251008185748-46e2fc7d3866 github.com/nrednav/cuid2 v1.1.0 github.com/oapi-codegen/runtime v1.1.2 github.com/stretchr/testify v1.10.0 @@ -47,4 +47,4 @@ require ( modernc.org/sqlite v1.23.1 // indirect ) -replace github.com/m1k1o/neko/server => github.com/onkernel/neko/server v0.0.0-20251008180819-e622f6212614 +replace github.com/m1k1o/neko/server => github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866 diff --git a/server/go.sum b/server/go.sum index af5a7dbc..e12ca801 100644 --- a/server/go.sum +++ b/server/go.sum @@ -58,8 +58,8 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= -github.com/onkernel/neko/server v0.0.0-20251008180819-e622f6212614 h1:iC/Y89xOyvp21MQOBwSjQpsccuBTSHj5NefXzWJkAsQ= -github.com/onkernel/neko/server v0.0.0-20251008180819-e622f6212614/go.mod h1:0+zactiySvtKwfe5JFjyNrSuQLA+EEPZl5bcfcZf1RM= +github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866 h1:Cix/sgZLCsavpiTFxDLPbUOXob50IekCg5mgh+i4D4Q= +github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866/go.mod h1:0+zactiySvtKwfe5JFjyNrSuQLA+EEPZl5bcfcZf1RM= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= From 99111f66a6dd44b0cc7ec4a909b6f1fdeebc08ff Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Wed, 8 Oct 2025 16:22:36 -0400 Subject: [PATCH 12/18] moar --- server/cmd/api/api/display.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index 5af2ec79..7e8921b9 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -219,10 +219,6 @@ func (s *ApiService) setResolutionXorgViaXrandr(ctx context.Context, width, heig return fmt.Errorf("xrandr failed: %s", stderr) } log.Info("resolution updated via xrandr", "display", display, "width", width, "height", height) - - if restartChrome { - s.restartChromium(ctx) - } return nil case oapi.ProcessExec400JSONResponse: return fmt.Errorf("bad request: %s", r.Message) From 5358c438f9690a5cf24993c1bf8de8f509a07069 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Wed, 8 Oct 2025 16:43:09 -0400 Subject: [PATCH 13/18] moar --- server/lib/nekoclient/client.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/lib/nekoclient/client.go b/server/lib/nekoclient/client.go index 9dbd960e..0f603904 100644 --- a/server/lib/nekoclient/client.go +++ b/server/lib/nekoclient/client.go @@ -104,6 +104,7 @@ func (c *AuthClient) SessionsGet(ctx context.Context) ([]nekooapi.SessionData, e // Handle 401 by clearing token and retrying once if resp.StatusCode == http.StatusUnauthorized { + resp.Body.Close() // Close the first response body before retrying c.clearToken() if err := c.ensureToken(ctx); err != nil { return nil, err @@ -160,6 +161,7 @@ func (c *AuthClient) ScreenConfigurationChange(ctx context.Context, config nekoo // Handle 401 by clearing token and retrying once if resp.StatusCode == http.StatusUnauthorized { + resp.Body.Close() // Close the first response body before retrying c.clearToken() if err := c.ensureToken(ctx); err != nil { return err From 613f0f9c2fea0770e96ece7bca66d0f474de250c Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Wed, 8 Oct 2025 23:37:10 -0400 Subject: [PATCH 14/18] cursor comments --- images/chromium-headful/run-docker.sh | 10 ++++++++-- images/chromium-headful/run-unikernel.sh | 10 ++++++++-- server/cmd/api/api/display.go | 3 ++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/images/chromium-headful/run-docker.sh b/images/chromium-headful/run-docker.sh index 803bc2cf..04781cd2 100755 --- a/images/chromium-headful/run-docker.sh +++ b/images/chromium-headful/run-docker.sh @@ -23,8 +23,14 @@ rm -rf .tmp/chromium mkdir -p .tmp/chromium FLAGS_FILE="$(pwd)/.tmp/chromium/flags" -# Convert space-separated flags to JSON array format -IFS=' ' read -ra FLAGS_ARRAY <<< "$CHROMIUM_FLAGS" +# Convert space-separated flags to JSON array format, handling quoted strings +# Use eval to properly parse quoted strings (respects shell quoting) +if [ -n "$CHROMIUM_FLAGS" ]; then + eval "FLAGS_ARRAY=($CHROMIUM_FLAGS)" +else + FLAGS_ARRAY=() +fi + FLAGS_JSON='{"flags":[' FIRST=true for flag in "${FLAGS_ARRAY[@]}"; do diff --git a/images/chromium-headful/run-unikernel.sh b/images/chromium-headful/run-unikernel.sh index 2c8c8a14..4c4ce546 100755 --- a/images/chromium-headful/run-unikernel.sh +++ b/images/chromium-headful/run-unikernel.sh @@ -29,8 +29,14 @@ rm -rf .tmp/chromium mkdir -p .tmp/chromium FLAGS_DIR=".tmp/chromium" -# Convert space-separated flags to JSON array format -IFS=' ' read -ra FLAGS_ARRAY <<< "$CHROMIUM_FLAGS" +# Convert space-separated flags to JSON array format, handling quoted strings +# Use eval to properly parse quoted strings (respects shell quoting) +if [ -n "$CHROMIUM_FLAGS" ]; then + eval "FLAGS_ARRAY=($CHROMIUM_FLAGS)" +else + FLAGS_ARRAY=() +fi + FLAGS_JSON='{"flags":[' FIRST=true for flag in "${FLAGS_ARRAY[@]}"; do diff --git a/server/cmd/api/api/display.go b/server/cmd/api/api/display.go index 7e8921b9..2b0a0289 100644 --- a/server/cmd/api/api/display.go +++ b/server/cmd/api/api/display.go @@ -350,7 +350,8 @@ func (s *ApiService) getCurrentResolution(ctx context.Context) (int, int, int) { display := s.resolveDisplayFromEnv() // Use xrandr to get current resolution - cmd := exec.CommandContext(ctx, "bash", "-lc", "xrandr | grep -E '\\*' | awk '{print $1}'") + // Note: Using bash -c (not -lc) to avoid login shell overriding DISPLAY env var + cmd := exec.CommandContext(ctx, "bash", "-c", "xrandr | grep -E '\\*' | awk '{print $1}'") cmd.Env = append(os.Environ(), fmt.Sprintf("DISPLAY=%s", display)) out, err := cmd.Output() From 2c71f0c7e94ed861bfc45f870c4fae189e737f27 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Thu, 9 Oct 2025 00:29:23 -0400 Subject: [PATCH 15/18] moar --- server/lib/nekoclient/client.go | 68 ++++++++++----------------------- 1 file changed, 20 insertions(+), 48 deletions(-) diff --git a/server/lib/nekoclient/client.go b/server/lib/nekoclient/client.go index 0f603904..68fc1533 100644 --- a/server/lib/nekoclient/client.go +++ b/server/lib/nekoclient/client.go @@ -2,9 +2,7 @@ package nekoclient import ( "context" - "encoding/json" "fmt" - "io" "net/http" "sync" @@ -14,7 +12,7 @@ import ( // AuthClient wraps the Neko OpenAPI client and handles authentication automatically. // It manages token caching and refresh on 401 responses. type AuthClient struct { - client *nekooapi.Client + client *nekooapi.ClientWithResponses tokenMu sync.Mutex token string username string @@ -23,7 +21,7 @@ type AuthClient struct { // NewAuthClient creates a new authenticated Neko client. func NewAuthClient(baseURL, username, password string) (*AuthClient, error) { - client, err := nekooapi.NewClient(baseURL) + client, err := nekooapi.NewClientWithResponses(baseURL) if err != nil { return nil, fmt.Errorf("failed to create neko client: %w", err) } @@ -49,27 +47,20 @@ func (c *AuthClient) ensureToken(ctx context.Context) error { Password: &c.password, } - resp, err := c.client.Login(ctx, loginReq) + resp, err := c.client.LoginWithResponse(ctx, loginReq) if err != nil { return fmt.Errorf("failed to call login API: %w", err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("login API returned status %d: %s", resp.StatusCode, string(respBody)) + if resp.StatusCode() != http.StatusOK { + return fmt.Errorf("login API returned status %d: %s", resp.StatusCode(), string(resp.Body)) } - var loginResp nekooapi.SessionLoginResponse - if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { - return fmt.Errorf("failed to parse login response: %w", err) - } - - if loginResp.Token == nil || *loginResp.Token == "" { + if resp.JSON200 == nil || resp.JSON200.Token == nil || *resp.JSON200.Token == "" { return fmt.Errorf("login response did not contain a token") } - c.token = *loginResp.Token + c.token = *resp.JSON200.Token return nil } @@ -96,44 +87,34 @@ func (c *AuthClient) SessionsGet(ctx context.Context) ([]nekooapi.SessionData, e } // Make the request - resp, err := c.client.SessionsGet(ctx, addAuth) + resp, err := c.client.SessionsGetWithResponse(ctx, addAuth) if err != nil { return nil, fmt.Errorf("failed to query sessions: %w", err) } - defer resp.Body.Close() // Handle 401 by clearing token and retrying once - if resp.StatusCode == http.StatusUnauthorized { - resp.Body.Close() // Close the first response body before retrying + if resp.StatusCode() == http.StatusUnauthorized { c.clearToken() if err := c.ensureToken(ctx); err != nil { return nil, err } // Retry with fresh token - addAuthRetry := func(ctx context.Context, req *http.Request) error { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) - return nil - } - - resp, err = c.client.SessionsGet(ctx, addAuthRetry) + resp, err = c.client.SessionsGetWithResponse(ctx, addAuth) if err != nil { return nil, fmt.Errorf("failed to retry sessions query: %w", err) } - defer resp.Body.Close() } - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("sessions API returned status %d: %s", resp.StatusCode, string(respBody)) + if resp.StatusCode() != http.StatusOK { + return nil, fmt.Errorf("sessions API returned status %d: %s", resp.StatusCode(), string(resp.Body)) } - var sessions []nekooapi.SessionData - if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { - return nil, fmt.Errorf("failed to parse sessions response: %w", err) + if resp.JSON200 == nil { + return nil, fmt.Errorf("sessions response did not contain expected data") } - return sessions, nil + return *resp.JSON200, nil } // ScreenConfigurationChange changes the screen resolution via Neko API. @@ -153,36 +134,27 @@ func (c *AuthClient) ScreenConfigurationChange(ctx context.Context, config nekoo } // Make the request - resp, err := c.client.ScreenConfigurationChange(ctx, config, addAuth) + resp, err := c.client.ScreenConfigurationChangeWithResponse(ctx, config, addAuth) if err != nil { return fmt.Errorf("failed to change screen configuration: %w", err) } - defer resp.Body.Close() // Handle 401 by clearing token and retrying once - if resp.StatusCode == http.StatusUnauthorized { - resp.Body.Close() // Close the first response body before retrying + if resp.StatusCode() == http.StatusUnauthorized { c.clearToken() if err := c.ensureToken(ctx); err != nil { return err } // Retry with fresh token - addAuthRetry := func(ctx context.Context, req *http.Request) error { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) - return nil - } - - resp, err = c.client.ScreenConfigurationChange(ctx, config, addAuthRetry) + resp, err = c.client.ScreenConfigurationChangeWithResponse(ctx, config, addAuth) if err != nil { return fmt.Errorf("failed to retry screen configuration change: %w", err) } - defer resp.Body.Close() } - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("screen configuration API returned status %d: %s", resp.StatusCode, string(respBody)) + if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusNoContent { + return fmt.Errorf("screen configuration API returned status %d: %s", resp.StatusCode(), string(resp.Body)) } return nil From 1e341502f3719499eb36671b475b1838a4ca306d Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Thu, 9 Oct 2025 18:03:18 -0400 Subject: [PATCH 16/18] Add lower resolution configs --- images/chromium-headful/xorg.conf | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/images/chromium-headful/xorg.conf b/images/chromium-headful/xorg.conf index 89594ad6..7e6196e7 100644 --- a/images/chromium-headful/xorg.conf +++ b/images/chromium-headful/xorg.conf @@ -66,6 +66,10 @@ Section "Monitor" Modeline "960x720_60.00" 55.86 960 1008 1104 1248 720 721 724 746 -HSync +Vsync # 800x600 @ 60.00 Hz (GTF) hsync: 37.32 kHz; pclk: 38.22 MHz Modeline "800x600_60.00" 38.22 800 832 912 1024 600 601 604 622 -HSync +Vsync + # 2560x1440 @ 60.00 Hz (GTF) hsync: 89.52 kHz; pclk: 312.25 MHz + Modeline "2560x1440_60.00" 312.25 2560 2752 3024 3488 1440 1443 1448 1493 -HSync +Vsync + # 1024x768 @ 60.00 Hz (GTF) hsync: 47.70 kHz; pclk: 63.50 MHz + Modeline "1024x768_60.00" 63.50 1024 1072 1176 1328 768 771 775 798 -HSync +Vsync # 2560x1440 @ 30.00 Hz (GTF) hsync: 43.95 kHz; pclk: 146.27 MHz Modeline "2560x1440_30.00" 146.27 2560 2680 2944 3328 1440 1441 1444 1465 -HSync +Vsync @@ -83,7 +87,12 @@ Section "Monitor" Modeline "960x720_30.00" 25.33 960 960 1056 1152 720 721 724 733 -HSync +Vsync # 800x600 @ 30.00 Hz (GTF) hsync: 18.33 kHz; pclk: 17.01 MHz Modeline "800x600_30.00" 17.01 800 792 864 928 600 601 604 611 -HSync +Vsync - + # 1920x1200 @ 30.00 Hz (GTF) hsync: 36.90 kHz; pclk: 96.00 MHz + Modeline "1920x1200_30.00" 96.00 1920 2000 2200 2528 1200 1203 1209 1235 -HSync +Vsync + # 1440x900 @ 30.00 Hz (GTF) hsync: 27.72 kHz; pclk: 52.80 MHz + Modeline "1440x900_30.00" 52.80 1440 1496 1648 1888 900 901 904 929 -HSync +Vsync + # 1024x768 @ 30.00 Hz (GTF) hsync: 23.55 kHz; pclk: 31.50 MHz + Modeline "1024x768_30.00" 31.50 1024 1048 1152 1280 768 771 775 790 -HSync +Vsync # 800x1600 @ 30.00 Hz (GTF) hsync: 48.84 kHz; pclk: 51.58 MHz Modeline "800x1600_30.00" 51.58 800 840 928 1056 1600 1601 1604 1628 -HSync +Vsync @@ -97,8 +106,25 @@ Section "Monitor" Modeline "1600x900_25.00" 45.75 1600 1648 1800 2000 900 903 908 916 -Hsync +Vsync # 1368x768 @ 24.89 Hz (CVT) hsync: 19.51 kHz; pclk: 33.25 MHz Modeline "1368x768_25.00" 33.25 1368 1408 1536 1704 768 771 781 784 -Hsync +Vsync + # 1920x1200 @ 25.00 Hz (GTF) hsync: 30.75 kHz; pclk: 80.00 MHz + Modeline "1920x1200_25.00" 80.00 1920 1968 2160 2464 1200 1203 1209 1231 -HSync +Vsync + # 1440x900 @ 25.00 Hz (GTF) hsync: 23.10 kHz; pclk: 44.00 MHz + Modeline "1440x900_25.00" 44.00 1440 1472 1616 1824 900 901 904 925 -HSync +Vsync + # 1024x768 @ 25.00 Hz (GTF) hsync: 19.62 kHz; pclk: 26.25 MHz + Modeline "1024x768_25.00" 26.25 1024 1032 1136 1248 768 771 775 787 -HSync +Vsync # 800x1600 @ 24.92 Hz (CVT) hsync: 40.53 kHz; pclk: 41.50 MHz Modeline "800x1600_25.00" 41.50 800 832 912 1024 1600 1603 1613 1626 -Hsync +Vsync + + # 2560x1440 @ 10.00 Hz (GTF) hsync: 14.65 kHz; pclk: 48.76 MHz + Modeline "2560x1440_10.00" 48.76 2560 2568 2816 3104 1440 1441 1444 1465 -HSync +Vsync + # 1920x1080 @ 10.00 Hz (GTF) hsync: 10.99 kHz; pclk: 26.73 MHz + Modeline "1920x1080_10.00" 26.73 1920 1920 2104 2304 1080 1081 1084 1099 -HSync +Vsync + # 1920x1200 @ 10.00 Hz (GTF) hsync: 12.30 kHz; pclk: 32.00 MHz + Modeline "1920x1200_10.00" 32.00 1920 1936 2120 2368 1200 1203 1209 1220 -HSync +Vsync + # 1440x900 @ 10.00 Hz (GTF) hsync: 9.24 kHz; pclk: 17.60 MHz + Modeline "1440x900_10.00" 17.60 1440 1424 1560 1680 900 901 904 917 -HSync +Vsync + # 1024x768 @ 10.00 Hz (GTF) hsync: 7.85 kHz; pclk: 10.50 MHz + Modeline "1024x768_10.00" 10.50 1024 1000 1096 1168 768 771 775 778 -HSync +Vsync EndSection Section "Screen" @@ -109,7 +135,7 @@ Section "Screen" SubSection "Display" Viewport 0 0 Depth 24 - Modes "1920x1080_60.00" "1920x1200_60.00" "1440x900_60.00" "1280x720_60.00" "1152x648_60.00" "1024x576_60.00" "960x720_60.00" "800x600_60.00" "2560x1440_30.00" "1920x1080_30.00" "1368x768_30.00" "1280x720_30.00" "1152x648_30.00" "1024x576_30.00" "960x720_30.00" "800x600_30.00" "3840x2160_25.00" "2560x1440_25.00" "1920x1080_25.00" "1600x900_25.00" "1368x768_25.00" "800x1600_30.00" "800x1600_25.00" + Modes "2560x1440_60.00" "1920x1080_60.00" "1920x1200_60.00" "1440x900_60.00" "1280x720_60.00" "1152x648_60.00" "1024x768_60.00" "1024x576_60.00" "960x720_60.00" "800x600_60.00" "2560x1440_30.00" "1920x1080_30.00" "1920x1200_30.00" "1440x900_30.00" "1368x768_30.00" "1280x720_30.00" "1152x648_30.00" "1024x768_30.00" "1024x576_30.00" "960x720_30.00" "800x600_30.00" "800x1600_30.00" "3840x2160_25.00" "2560x1440_25.00" "1920x1080_25.00" "1920x1200_25.00" "1600x900_25.00" "1440x900_25.00" "1368x768_25.00" "1024x768_25.00" "800x1600_25.00" "2560x1440_10.00" "1920x1080_10.00" "1920x1200_10.00" "1440x900_10.00" "1024x768_10.00" EndSubSection EndSection From 6a7b56047928f8946d65be917fe605e74b6e0f8f Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Fri, 10 Oct 2025 14:37:51 -0400 Subject: [PATCH 17/18] one moar --- server/cmd/api/api/api.go | 14 +++----------- server/cmd/api/main.go | 13 +++++++++++++ server/openapi.yaml | 2 +- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 975d214c..00364808 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -40,7 +40,7 @@ type ApiService struct { var _ oapi.StrictServerInterface = (*ApiService)(nil) -func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory, upstreamMgr *devtoolsproxy.UpstreamManager, stz scaletozero.Controller) (*ApiService, error) { +func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory, upstreamMgr *devtoolsproxy.UpstreamManager, stz scaletozero.Controller, nekoAuthClient *nekoclient.AuthClient) (*ApiService, error) { switch { case recordManager == nil: return nil, fmt.Errorf("recordManager cannot be nil") @@ -48,16 +48,8 @@ func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFa return nil, fmt.Errorf("factory cannot be nil") case upstreamMgr == nil: return nil, fmt.Errorf("upstreamMgr cannot be nil") - } - - // Initialize Neko authenticated client - adminPassword := os.Getenv("NEKO_ADMIN_PASSWORD") - if adminPassword == "" { - adminPassword = "admin" // Default from neko.yaml - } - nekoAuthClient, err := nekoclient.NewAuthClient("http://127.0.0.1:8080", "admin", adminPassword) - if err != nil { - return nil, fmt.Errorf("failed to create neko auth client: %w", err) + case nekoAuthClient == nil: + return nil, fmt.Errorf("nekoAuthClient cannot be nil") } return &ApiService{ diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 29256a30..cc6dbf86 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -23,6 +23,7 @@ import ( "github.com/onkernel/kernel-images/server/cmd/config" "github.com/onkernel/kernel-images/server/lib/devtoolsproxy" "github.com/onkernel/kernel-images/server/lib/logger" + "github.com/onkernel/kernel-images/server/lib/nekoclient" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" "github.com/onkernel/kernel-images/server/lib/scaletozero" @@ -75,11 +76,23 @@ func main() { upstreamMgr := devtoolsproxy.NewUpstreamManager(chromiumLogPath, slogger) upstreamMgr.Start(ctx) + // Initialize Neko authenticated client + adminPassword := os.Getenv("NEKO_ADMIN_PASSWORD") + if adminPassword == "" { + adminPassword = "admin" // Default from neko.yaml + } + nekoAuthClient, err := nekoclient.NewAuthClient("http://127.0.0.1:8080", "admin", adminPassword) + if err != nil { + slogger.Error("failed to create neko auth client", "err", err) + os.Exit(1) + } + apiService, err := api.New( recorder.NewFFmpegManager(), recorder.NewFFmpegRecorderFactory(config.PathToFFmpeg, defaultParams, stz), upstreamMgr, stz, + nekoAuthClient, ) if err != nil { slogger.Error("failed to create api service", "err", err) diff --git a/server/openapi.yaml b/server/openapi.yaml index 998ec40f..d09992a0 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1180,7 +1180,7 @@ components: description: Display height in pixels refresh_rate: type: integer - enum: [60, 30, 25] + enum: [60, 30, 25, 10] description: Display refresh rate in Hz. If omitted, uses the highest available rate for the resolution. require_idle: type: boolean From 513dcbbadef9e117ada1f115379d1dfaf8c246a2 Mon Sep 17 00:00:00 2001 From: Hiro Tamada Date: Fri, 10 Oct 2025 14:48:55 -0400 Subject: [PATCH 18/18] Fix tests --- server/cmd/api/api/api_test.go | 29 +++++--- server/lib/oapi/oapi.go | 129 +++++++++++++++++---------------- 2 files changed, 84 insertions(+), 74 deletions(-) diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index f8288cb9..783786de 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -12,6 +12,7 @@ import ( "log/slog" "github.com/onkernel/kernel-images/server/lib/devtoolsproxy" + "github.com/onkernel/kernel-images/server/lib/nekoclient" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" "github.com/onkernel/kernel-images/server/lib/scaletozero" @@ -24,7 +25,7 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("success", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t)) require.NoError(t, err) resp, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) @@ -38,7 +39,7 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("already recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t)) require.NoError(t, err) // First start should succeed @@ -53,7 +54,7 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("custom ids don't collide", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t)) require.NoError(t, err) for i := 0; i < 5; i++ { @@ -86,7 +87,7 @@ func TestApiService_StopRecording(t *testing.T) { t.Run("no active recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t)) require.NoError(t, err) resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) @@ -99,7 +100,7 @@ func TestApiService_StopRecording(t *testing.T) { rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t)) require.NoError(t, err) resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) require.NoError(t, err) @@ -114,7 +115,7 @@ func TestApiService_StopRecording(t *testing.T) { force := true req := oapi.StopRecordingRequestObject{Body: &oapi.StopRecordingJSONRequestBody{ForceStop: &force}} - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t)) require.NoError(t, err) resp, err := svc.StopRecording(ctx, req) require.NoError(t, err) @@ -128,7 +129,7 @@ func TestApiService_DownloadRecording(t *testing.T) { t.Run("not found", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t)) require.NoError(t, err) resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) @@ -148,7 +149,7 @@ func TestApiService_DownloadRecording(t *testing.T) { rec := &mockRecorder{id: "default", isRecordingFlag: true, recordingData: randomBytes(minRecordingSizeInBytes - 1)} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t)) require.NoError(t, err) // will return a 202 when the recording is too small resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) @@ -178,7 +179,7 @@ func TestApiService_DownloadRecording(t *testing.T) { rec := &mockRecorder{id: "default", recordingData: data} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t)) require.NoError(t, err) resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) @@ -198,7 +199,7 @@ func TestApiService_Shutdown(t *testing.T) { rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController()) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t)) require.NoError(t, err) require.NoError(t, svc.Shutdown(ctx)) @@ -293,3 +294,11 @@ func newTestUpstreamManager() *devtoolsproxy.UpstreamManager { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) return devtoolsproxy.NewUpstreamManager("", logger) } + +func newMockNekoClient(t *testing.T) *nekoclient.AuthClient { + // Create a mock client that won't actually connect to anything + // We use a dummy URL since tests don't need real Neko connectivity + client, err := nekoclient.NewAuthClient("http://localhost:9999", "admin", "admin") + require.NoError(t, err) + return client +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 81d01411..16ea26f2 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -51,6 +51,7 @@ const ( // Defines values for PatchDisplayRequestRefreshRate. const ( + N10 PatchDisplayRequestRefreshRate = 10 N25 PatchDisplayRequestRefreshRate = 25 N30 PatchDisplayRequestRefreshRate = 30 N60 PatchDisplayRequestRefreshRate = 60 @@ -8821,70 +8822,70 @@ var swaggerSpec = []string{ "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", - "AAD//1BnF8XFcgAA", + "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", + "+f8AAAD//x3S4UfIcgAA", } // GetSwagger returns the content of the embedded swagger specification file