diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 55436165..520b9ca1 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -129,6 +129,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/images/chromium-headful/run-docker.sh b/images/chromium-headful/run-docker.sh index 62361096..04781cd2 100755 --- a/images/chromium-headful/run-docker.sh +++ b/images/chromium-headful/run-docker.sh @@ -22,7 +22,29 @@ 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, 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 + 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..4c4ce546 100755 --- a/images/chromium-headful/run-unikernel.sh +++ b/images/chromium-headful/run-unikernel.sh @@ -28,7 +28,32 @@ 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, 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 + 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 diff --git a/images/chromium-headful/xorg.conf b/images/chromium-headful/xorg.conf index ed0e8488..7e6196e7 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 @@ -62,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 @@ -79,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 @@ -93,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" @@ -105,7 +135,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 "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 diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 4cc3b163..00364808 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,15 +30,17 @@ type ApiService struct { procMu sync.RWMutex procs map[string]*processHandle + // 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) -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") @@ -45,6 +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") + case nekoAuthClient == nil: + return nil, fmt.Errorf("nekoAuthClient cannot be nil") } return &ApiService{ @@ -55,6 +60,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/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/cmd/api/api/display.go b/server/cmd/api/api/display.go new file mode 100644 index 00000000..2b0a0289 --- /dev/null +++ b/server/cmd/api/api/display.go @@ -0,0 +1,426 @@ +package api + +import ( + "context" + "encoding/base64" + "fmt" + "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" +) + +// 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) 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 + } + + // 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 with refresh rate + currentWidth, currentHeight, currentRefreshRate := s.getCurrentResolution(ctx) + width := currentWidth + height := currentHeight + refreshRate := currentRefreshRate + + if req.Body.Width != nil { + width = *req.Body.Width + } + 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(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 + if req.Body.RequireIdle != nil { + requireIdle = *req.Body.RequireIdle + } + + // Check if resize is safe (no active sessions or recordings) + if requireIdle { + live := s.getActiveNekoSessions(ctx) + isRecording := s.anyRecordingActive(ctx) + resizableNow := (live == 0) && !isRecording + + log.Info("checking if resize is safe", "live_sessions", live, "is_recording", isRecording, "resizable", resizableNow) + + if !resizableNow { + return oapi.PatchDisplay409JSONResponse{ + ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{ + Message: "resize refused: live view or recording/replay active", + }, + }, nil + } + } + + // 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 + } + + // 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.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) + } + + 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, + RefreshRate: &refreshRate, + }, nil +} + +// 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}) + + 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" { + log.Info("detected Xvfb display (headless mode)") + return "xvfb" + } + } + } + } + log.Info("detected Xorg display (headful mode)") + return "xorg" +} + +// 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) + 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()) + } +} + +// 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)) + } + } + 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) + 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") + } +} + +// 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) + } + + // 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") + } + } + + // 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) + } + + // 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}) + + if restartChrome { + s.restartChromium(ctx) + } + + log.Info("Xvfb resolution updated", "width", width, "height", height) + return 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 +} + +// getActiveNekoSessions queries the Neko API for active viewer sessions. +func (s *ApiService) getActiveNekoSessions(ctx context.Context) int { + log := logger.FromContext(ctx) + + // Query sessions using authenticated client + sessions, err := s.nekoAuthClient.SessionsGet(ctx) + if err != nil { + log.Debug("failed to query Neko sessions", "error", err) + return 0 + } + + // Count active sessions (connected and watching) + live := 0 + for i, session := range sessions { + log.Info("neko session details", "index", i, "session", session) + if session.State != nil { + connected := session.State.IsConnected != nil && *session.State.IsConnected + watching := session.State.IsWatching != nil && *session.State.IsWatching + if connected && watching { + live++ + } + } + } + + log.Info("successfully queried Neko API", "active_sessions", live) + return live +} + +// 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" +} + +// 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() + + // Use xrandr to get current resolution + // 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() + if err != nil { + log.Error("failed to get current resolution", "error", err) + // Return default resolution on error + 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, 60 + } + + width, err := strconv.Atoi(parts[0]) + if err != nil { + log.Error("failed to parse width", "error", err, "value", parts[0]) + 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(heightStr) + if err != nil { + log.Error("failed to parse height", "error", err, "value", heightStr) + return 1024, 768, 60 + } + + return width, height, refreshRate +} + +// 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, refreshRate int) error { + log := logger.FromContext(ctx) + + // Use default refresh rate if not specified + if refreshRate <= 0 { + refreshRate = 60 + } + + // Prepare screen configuration + screenConfig := nekooapi.ScreenConfiguration{ + Width: &width, + Height: &height, + Rate: &refreshRate, + } + + // 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) + return nil +} 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/e2e/e2e_chromium_test.go b/server/e2e/e2e_chromium_test.go index b655d5f6..4df3b061 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") +} diff --git a/server/go.mod b/server/go.mod index 15c3a663..66ede4e5 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-20251008185748-46e2fc7d3866 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-20251008185748-46e2fc7d3866 diff --git a/server/go.sum b/server/go.sum index 0b8d7092..e12ca801 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-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 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..68fc1533 --- /dev/null +++ b/server/lib/nekoclient/client.go @@ -0,0 +1,161 @@ +package nekoclient + +import ( + "context" + "fmt" + "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.ClientWithResponses + 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.NewClientWithResponses(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.LoginWithResponse(ctx, loginReq) + if err != nil { + return fmt.Errorf("failed to call login API: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return fmt.Errorf("login API returned status %d: %s", resp.StatusCode(), string(resp.Body)) + } + + if resp.JSON200 == nil || resp.JSON200.Token == nil || *resp.JSON200.Token == "" { + return fmt.Errorf("login response did not contain a token") + } + + c.token = *resp.JSON200.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.SessionsGetWithResponse(ctx, addAuth) + if err != nil { + return nil, fmt.Errorf("failed to query sessions: %w", err) + } + + // 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 + resp, err = c.client.SessionsGetWithResponse(ctx, addAuth) + if err != nil { + return nil, fmt.Errorf("failed to retry sessions query: %w", err) + } + } + + if resp.StatusCode() != http.StatusOK { + return nil, fmt.Errorf("sessions API returned status %d: %s", resp.StatusCode(), string(resp.Body)) + } + + if resp.JSON200 == nil { + return nil, fmt.Errorf("sessions response did not contain expected data") + } + + return *resp.JSON200, 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.ScreenConfigurationChangeWithResponse(ctx, config, addAuth) + if err != nil { + return fmt.Errorf("failed to change screen configuration: %w", err) + } + + // 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 + resp, err = c.client.ScreenConfigurationChangeWithResponse(ctx, config, addAuth) + if err != nil { + return fmt.Errorf("failed to retry screen configuration change: %w", err) + } + } + + 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 +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 66f78543..16ea26f2 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -49,6 +49,14 @@ const ( WRITE FileSystemEventType = "WRITE" ) +// Defines values for PatchDisplayRequestRefreshRate. +const ( + N10 PatchDisplayRequestRefreshRate = 10 + N25 PatchDisplayRequestRefreshRate = 25 + N30 PatchDisplayRequestRefreshRate = 30 + N60 PatchDisplayRequestRefreshRate = 60 +) + // Defines values for ProcessKillRequestSignal. const ( HUP ProcessKillRequestSignal = "HUP" @@ -128,6 +136,18 @@ type DeleteRecordingRequest struct { Id *string `json:"id,omitempty"` } +// DisplayConfig defines model for DisplayConfig. +type DisplayConfig struct { + // Height Current display height in pixels + Height *int `json:"height,omitempty"` + + // RefreshRate Current display refresh rate in Hz (may be null if not detectable) + RefreshRate *int `json:"refresh_rate,omitempty"` + + // Width Current display width in pixels + Width *int `json:"width,omitempty"` +} + // Error defines model for Error. type Error struct { Message string `json:"message"` @@ -211,6 +231,27 @@ 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"` + + // 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. @@ -487,6 +528,9 @@ type ClickMouseJSONRequestBody = ClickMouseRequest // MoveMouseJSONRequestBody defines body for MoveMouse for application/json ContentType. type MoveMouseJSONRequestBody = MoveMouseRequest +// PatchDisplayJSONRequestBody defines body for PatchDisplay for application/json ContentType. +type PatchDisplayJSONRequestBody = PatchDisplayRequest + // CreateDirectoryJSONRequestBody defines body for CreateDirectory for application/json ContentType. type CreateDirectoryJSONRequestBody = CreateDirectoryRequest @@ -618,6 +662,11 @@ type ClientInterface interface { MoveMouse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // PatchDisplayWithBody request with any body + PatchDisplayWithBody(ctx context.Context, contentType string, body io.Reader, 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) @@ -786,6 +835,30 @@ func (c *Client) MoveMouse(ctx context.Context, body MoveMouseJSONRequestBody, r return c.Client.Do(req) } +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 + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +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 + } + 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 { @@ -1375,6 +1448,46 @@ func NewMoveMouseRequestWithBody(server string, contentType string, body io.Read return req, nil } +// 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 NewPatchDisplayRequestWithBody(server, "application/json", bodyReader) +} + +// 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) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/display") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PATCH", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + 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 @@ -2571,6 +2684,11 @@ type ClientWithResponsesInterface interface { MoveMouseWithResponse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) + // PatchDisplayWithBodyWithResponse request with any body + PatchDisplayWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchDisplayResponse, 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) @@ -2748,6 +2866,31 @@ func (r MoveMouseResponse) StatusCode() int { return 0 } +type PatchDisplayResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DisplayConfig + JSON400 *BadRequestError + JSON409 *ConflictError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r PatchDisplayResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PatchDisplayResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type CreateDirectoryResponse struct { Body []byte HTTPResponse *http.Response @@ -3442,6 +3585,23 @@ func (c *ClientWithResponses) MoveMouseWithResponse(ctx context.Context, body Mo return ParseMoveMouseResponse(rsp) } +// 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 ParsePatchDisplayResponse(rsp) +} + +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 ParsePatchDisplayResponse(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...) @@ -3888,6 +4048,53 @@ func ParseMoveMouseResponse(rsp *http.Response) (*MoveMouseResponse, error) { return response, nil } +// 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 := &PatchDisplayResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DisplayConfig + 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 +} + // ParseCreateDirectoryResponse parses an HTTP response from a CreateDirectoryWithResponse call func ParseCreateDirectoryResponse(rsp *http.Response) (*CreateDirectoryResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -4979,6 +5186,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) + // 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) @@ -5084,6 +5294,12 @@ func (_ Unimplemented) MoveMouse(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Update display configuration +// (PATCH /display) +func (_ Unimplemented) PatchDisplay(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) { @@ -5297,6 +5513,20 @@ func (siw *ServerInterfaceWrapper) MoveMouse(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } +// 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.PatchDisplay(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) { @@ -6028,6 +6258,9 @@ 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.Patch(options.BaseURL+"/display", wrapper.PatchDisplay) + }) r.Group(func(r chi.Router) { r.Put(options.BaseURL+"/fs/create_directory", wrapper.CreateDirectory) }) @@ -6223,6 +6456,50 @@ func (response MoveMouse500JSONResponse) VisitMoveMouseResponse(w http.ResponseW return json.NewEncoder(w).Encode(response) } +type PatchDisplayRequestObject struct { + Body *PatchDisplayJSONRequestBody +} + +type PatchDisplayResponseObject interface { + VisitPatchDisplayResponse(w http.ResponseWriter) error +} + +type PatchDisplay200JSONResponse DisplayConfig + +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 PatchDisplay400JSONResponse struct{ BadRequestErrorJSONResponse } + +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 PatchDisplay409JSONResponse struct{ ConflictErrorJSONResponse } + +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 PatchDisplay500JSONResponse struct{ InternalErrorJSONResponse } + +func (response PatchDisplay500JSONResponse) VisitPatchDisplayResponse(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 } @@ -7500,6 +7777,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) + // 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) @@ -7705,6 +7985,37 @@ func (sh *strictHandler) MoveMouse(w http.ResponseWriter, r *http.Request) { } } +// PatchDisplay operation middleware +func (sh *strictHandler) PatchDisplay(w http.ResponseWriter, r *http.Request) { + var request PatchDisplayRequestObject + + 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 + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.PatchDisplay(ctx, request.(PatchDisplayRequestObject)) + } + for _, middleware := range sh.middlewares { + 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.(PatchDisplayResponseObject); ok { + if err := validResponse.VisitPatchDisplayResponse(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 @@ -8489,86 +8800,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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", + "+f8AAAD//x3S4UfIcgAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index cece5906..d09992a0 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -737,6 +737,29 @@ paths: $ref: "#/components/responses/NotFoundError" "500": $ref: "#/components/responses/InternalError" + /display: + patch: + summary: Update display configuration + operationId: patchDisplay + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PatchDisplayRequest" + responses: + "200": + description: Updated display configuration + content: + application/json: + schema: + $ref: "#/components/schemas/DisplayConfig" + "409": + $ref: "#/components/responses/ConflictError" + "400": + $ref: "#/components/responses/BadRequestError" + "500": + $ref: "#/components/responses/InternalError" /chromium/upload-extensions-and-restart: post: @@ -827,14 +850,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 @@ -1025,9 +1046,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 @@ -1036,17 +1056,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 @@ -1093,8 +1111,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 @@ -1150,6 +1167,42 @@ components: type: integer description: Exit code when the event is "exit". additionalProperties: false + PatchDisplayRequest: + type: object + properties: + width: + type: integer + minimum: 320 + description: Display width in pixels + height: + type: integer + minimum: 240 + description: Display height in pixels + refresh_rate: + type: integer + 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 + 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 + properties: + width: + type: integer + description: Current display width in pixels + 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) + additionalProperties: false LogEvent: type: object description: A log entry from the application.