diff --git a/devices/ios.go b/devices/ios.go index e1751f3..7c3bf3f 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -1,6 +1,8 @@ package devices import ( + "encoding/binary" + "encoding/json" "context" "errors" "fmt" @@ -10,6 +12,7 @@ import ( "os/signal" "strconv" "strings" + "sync" "syscall" "time" @@ -45,9 +48,11 @@ type IOSDevice struct { DeviceName string `json:"DeviceName"` OSVersion string `json:"Version"` - tunnelManager *ios.TunnelManager - wdaClient *wda.WdaClient - mjpegClient *mjpeg.WdaMjpegClient + tunnelManager *ios.TunnelManager + wdaClient *wda.WdaClient + mjpegClient *mjpeg.WdaMjpegClient + screenCaptureConn net.Conn + screenCaptureConnLock sync.Mutex } func (d IOSDevice) ID() string { @@ -713,6 +718,11 @@ func (d IOSDevice) StartScreenCapture(config ScreenCaptureConfig) error { return fmt.Errorf("failed to connect to stream port: %w", err) } + // store connection for configuration updates + d.screenCaptureConnLock.Lock() + d.screenCaptureConn = conn + d.screenCaptureConnLock.Unlock() + // setup signal handling for Ctrl+C sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) @@ -722,7 +732,12 @@ func (d IOSDevice) StartScreenCapture(config ScreenCaptureConfig) error { // stream data in a goroutine go func() { - defer conn.Close() + defer func() { + conn.Close() + d.screenCaptureConnLock.Lock() + d.screenCaptureConn = nil + d.screenCaptureConnLock.Unlock() + }() buffer := make([]byte, 65536) for { n, err := conn.Read(buffer) @@ -775,6 +790,68 @@ func (d IOSDevice) StartScreenCapture(config ScreenCaptureConfig) error { return d.mjpegClient.StartScreenCapture(config.Format, config.OnData) } +// SendScreenCaptureConfiguration sends encoder configuration updates to the device over the TCP stream. +// +// This method sends a length-prefixed JSON-RPC message to update the H.264 encoder +// bitrate and optionally frame rate dynamically without restarting the stream. +// +// Parameters: +// - bitrate: Target bitrate in bits per second (100000 - 8000000) +// - frameRate: Optional target frame rate (nil to keep current, 1-60 if provided) +// +// The message format is: +// [4-byte big-endian length][JSON-RPC payload] +// +// Returns an error if: +// - Screen capture is not active +// - JSON marshaling fails +// - TCP write fails +func (d *IOSDevice) SendScreenCaptureConfiguration(bitrate int, frameRate *int) error { + // Check if screen capture is active (TCP connection exists) + d.screenCaptureConnLock.Lock() + conn := d.screenCaptureConn + d.screenCaptureConnLock.Unlock() + + if conn == nil { + return fmt.Errorf("screen capture not active, start screencapture first") + } + + // Build JSON-RPC request + request := map[string]interface{}{ + "jsonrpc": "2.0", + "method": "screencapture.setConfiguration", + "params": map[string]interface{}{ + "bitrate": bitrate, + "frameRate": frameRate, + }, + "id": 1, + } + + jsonData, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // Prepend 4-byte length (big-endian) + length := uint32(len(jsonData)) + lengthBytes := make([]byte, 4) + binary.BigEndian.PutUint32(lengthBytes, length) + + // Send: [4-byte length][JSON payload] + message := append(lengthBytes, jsonData...) + + d.screenCaptureConnLock.Lock() + _, err = conn.Write(message) + d.screenCaptureConnLock.Unlock() + + if err != nil { + return fmt.Errorf("failed to send configuration: %w", err) + } + + utils.Verbose("Sent screen capture configuration: bitrate=%d bps, frameRate=%v", bitrate, frameRate) + return nil +} + func (d IOSDevice) DumpSource() ([]ScreenElement, error) { return d.wdaClient.GetSourceElements() } diff --git a/server/dispatch.go b/server/dispatch.go index b7ce58d..21c667f 100644 --- a/server/dispatch.go +++ b/server/dispatch.go @@ -12,25 +12,26 @@ type HandlerFunc func(params json.RawMessage) (interface{}, error) // This is used by both the HTTP server and embedded clients func GetMethodRegistry() map[string]HandlerFunc { return map[string]HandlerFunc{ - "devices": handleDevicesList, - "screenshot": handleScreenshot, - "io_tap": handleIoTap, - "io_longpress": handleIoLongPress, - "io_text": handleIoText, - "io_button": handleIoButton, - "io_swipe": handleIoSwipe, - "io_gesture": handleIoGesture, - "url": handleURL, - "device_info": handleDeviceInfo, - "io_orientation_get": handleIoOrientationGet, - "io_orientation_set": handleIoOrientationSet, - "device_boot": handleDeviceBoot, - "device_shutdown": handleDeviceShutdown, - "device_reboot": handleDeviceReboot, - "dump_ui": handleDumpUI, - "apps_launch": handleAppsLaunch, - "apps_terminate": handleAppsTerminate, - "apps_list": handleAppsList, + "devices": handleDevicesList, + "screenshot": handleScreenshot, + "screencapture.setConfiguration": handleScreenCaptureSetConfiguration, + "io_tap": handleIoTap, + "io_longpress": handleIoLongPress, + "io_text": handleIoText, + "io_button": handleIoButton, + "io_swipe": handleIoSwipe, + "io_gesture": handleIoGesture, + "url": handleURL, + "device_info": handleDeviceInfo, + "io_orientation_get": handleIoOrientationGet, + "io_orientation_set": handleIoOrientationSet, + "device_boot": handleDeviceBoot, + "device_shutdown": handleDeviceShutdown, + "device_reboot": handleDeviceReboot, + "dump_ui": handleDumpUI, + "apps_launch": handleAppsLaunch, + "apps_terminate": handleAppsTerminate, + "apps_list": handleAppsList, } } diff --git a/server/server.go b/server/server.go index cd2c229..a1aab98 100644 --- a/server/server.go +++ b/server/server.go @@ -45,6 +45,11 @@ const ( IdleTimeout = 120 * time.Second ) +const ( + ScreenCaptureMinBitrate = 100000 + ScreenCaptureMaxBitrate = 8000000 +) + var okResponse = map[string]interface{}{"status": "ok"} type JSONRPCRequest struct { @@ -292,6 +297,66 @@ func handleScreenshot(params json.RawMessage) (interface{}, error) { return nil, fmt.Errorf("unexpected response format") } +type ScreenCaptureSetConfigurationParams struct { + DeviceID string `json:"deviceId"` + Bitrate int `json:"bitrate"` + FrameRate *int `json:"frameRate,omitempty"` +} + +type ScreenCaptureSetConfigurationResponse struct { + Success bool `json:"success"` + DeviceID string `json:"deviceId"` + Bitrate int `json:"bitrate"` + FrameRate *int `json:"frameRate,omitempty"` +} + +func handleScreenCaptureSetConfiguration(params json.RawMessage) (interface{}, error) { + var req ScreenCaptureSetConfigurationParams + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid parameters: %v", err) + } + + // Validate bitrate range (100 kbps to 8 Mbps) + if req.Bitrate < ScreenCaptureMinBitrate || req.Bitrate > ScreenCaptureMaxBitrate { + return nil, fmt.Errorf("bitrate must be between %d and %d bps", ScreenCaptureMinBitrate, ScreenCaptureMaxBitrate) + } + + // Validate frame rate if provided + if req.FrameRate != nil { + if *req.FrameRate < 1 || *req.FrameRate > 60 { + return nil, fmt.Errorf("frame rate must be between 1 and 60") + } + } + + targetDevice, err := commands.FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return nil, fmt.Errorf("error finding device: %w", err) + } + + // Only iOS real devices support this + if targetDevice.Platform() != "ios" || targetDevice.DeviceType() != "real" { + return nil, fmt.Errorf("screencapture.setConfiguration only supported on real iOS devices") + } + + iosDevice, ok := targetDevice.(*devices.IOSDevice) + if !ok { + return nil, fmt.Errorf("device is not an iOS device") + } + + // Send to device over TCP socket + err = iosDevice.SendScreenCaptureConfiguration(req.Bitrate, req.FrameRate) + if err != nil { + return nil, fmt.Errorf("failed to update configuration: %w", err) + } + + return ScreenCaptureSetConfigurationResponse{ + Success: true, + DeviceID: req.DeviceID, + Bitrate: req.Bitrate, + FrameRate: req.FrameRate, + }, nil +} + type IoTapParams struct { DeviceID string `json:"deviceId"` X int `json:"x"`