From 3159f44d45e8ba65d22e5c543ddc8eaa7058ca44 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 30 Jun 2026 10:26:58 +0200 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20rename=20sessionId=20=E2=86=92?= =?UTF-8?q?=20allocationId=20in=20fleet=20allocation=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FleetAllocateResponse.SessionID → AllocationID (json:"allocationId") DeviceProvider.SessionID → AllocationID (json:"allocationId,omitempty") All call sites in cli/fleet.go and cli/remote.go updated. --- cli/fleet.go | 12 ++++++------ cli/remote.go | 12 ++++++------ commands/remote.go | 10 +++++----- devices/common.go | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cli/fleet.go b/cli/fleet.go index f386c36..1c274ea 100644 --- a/cli/fleet.go +++ b/cli/fleet.go @@ -79,27 +79,27 @@ Name supports wildcard prefix matching: } if result.IsAllocating() { - utils.Verbose("waiting for device allocation, session %s (0 seconds elapsed)", result.SessionID) + utils.Verbose("waiting for device allocation, session %s (0 seconds elapsed)", result.AllocationID) start := time.Now() deadline := start.Add(time.Duration(fleetTimeout) * time.Second) for { if time.Now().After(deadline) { - err := fmt.Errorf("timed out waiting for device allocation after %d seconds (session %s)", fleetTimeout, result.SessionID) + err := fmt.Errorf("timed out waiting for device allocation after %d seconds (session %s)", fleetTimeout, result.AllocationID) printJson(commands.NewErrorResponse(err)) return err } time.Sleep(5 * time.Second) elapsed := int(time.Since(start).Seconds()) - utils.Verbose("waiting for device allocation, session %s (%d seconds elapsed)", result.SessionID, elapsed) - device, err := commands.FleetGetDeviceBySession(token, result.SessionID) + utils.Verbose("waiting for device allocation, session %s (%d seconds elapsed)", result.AllocationID, elapsed) + device, err := commands.FleetGetDeviceBySession(token, result.AllocationID) if err != nil { - err = fmt.Errorf("failed to check device status (session %s): %w", result.SessionID, err) + err = fmt.Errorf("failed to check device status (session %s): %w", result.AllocationID, err) printJson(commands.NewErrorResponse(err)) return err } if device.State != "allocating" { response = commands.NewSuccessResponse(commands.FleetAllocateResponse{ - SessionID: result.SessionID, + AllocationID: result.AllocationID, ProvisionID: result.ProvisionID, State: device.State, Device: commands.FleetAllocateDevice{ diff --git a/cli/remote.go b/cli/remote.go index df9836a..9c6a171 100644 --- a/cli/remote.go +++ b/cli/remote.go @@ -78,27 +78,27 @@ Name supports wildcard prefix matching: } if result.IsAllocating() { - utils.Verbose("waiting for device allocation, session %s (0 seconds elapsed)", result.SessionID) + utils.Verbose("waiting for device allocation, session %s (0 seconds elapsed)", result.AllocationID) start := time.Now() deadline := start.Add(time.Duration(fleetTimeout) * time.Second) for { if time.Now().After(deadline) { - err := fmt.Errorf("timed out waiting for device allocation after %d seconds (session %s)", fleetTimeout, result.SessionID) + err := fmt.Errorf("timed out waiting for device allocation after %d seconds (session %s)", fleetTimeout, result.AllocationID) printJson(commands.NewErrorResponse(err)) return err } time.Sleep(5 * time.Second) elapsed := int(time.Since(start).Seconds()) - utils.Verbose("waiting for device allocation, session %s (%d seconds elapsed)", result.SessionID, elapsed) - device, err := commands.FleetGetDeviceBySession(token, result.SessionID) + utils.Verbose("waiting for device allocation, session %s (%d seconds elapsed)", result.AllocationID, elapsed) + device, err := commands.FleetGetDeviceBySession(token, result.AllocationID) if err != nil { - err = fmt.Errorf("failed to check device status (session %s): %w", result.SessionID, err) + err = fmt.Errorf("failed to check device status (session %s): %w", result.AllocationID, err) printJson(commands.NewErrorResponse(err)) return err } if device.State != "allocating" { response = commands.NewSuccessResponse(commands.FleetAllocateResponse{ - SessionID: result.SessionID, + AllocationID: result.AllocationID, ProvisionID: result.ProvisionID, State: device.State, Device: commands.FleetAllocateDevice{ diff --git a/commands/remote.go b/commands/remote.go index 9bbedc5..b0f4873 100644 --- a/commands/remote.go +++ b/commands/remote.go @@ -30,10 +30,10 @@ type FleetAllocateDevice struct { } type FleetAllocateResponse struct { - SessionID string `json:"sessionId"` - Device FleetAllocateDevice `json:"device"` - ProvisionID string `json:"provisionId,omitempty"` - State string `json:"state,omitempty"` + AllocationID string `json:"allocationId"` + Device FleetAllocateDevice `json:"device"` + ProvisionID string `json:"provisionId,omitempty"` + State string `json:"state,omitempty"` } func (r FleetAllocateResponse) IsAllocating() bool { @@ -93,7 +93,7 @@ func FleetGetDeviceBySession(token, sessionID string) (*devices.DeviceInfo, erro for _, d := range result.Devices { var p devices.DeviceProvider - if json.Unmarshal(d.Provider, &p) == nil && p.SessionID == sessionID { + if json.Unmarshal(d.Provider, &p) == nil && p.AllocationID == sessionID { return &d, nil } } diff --git a/devices/common.go b/devices/common.go index b5a3639..36a13fb 100644 --- a/devices/common.go +++ b/devices/common.go @@ -271,7 +271,7 @@ type DeviceListOptions struct { type DeviceProvider struct { Type string `json:"type"` - SessionID string `json:"sessionId,omitempty"` + AllocationID string `json:"allocationId,omitempty"` } type DeviceInfo struct { From 825e4aecf30638283df350febb50ca0711c037cd Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 30 Jun 2026 10:45:16 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Revert=20"refactor:=20rename=20sessionId=20?= =?UTF-8?q?=E2=86=92=20allocationId=20in=20fleet=20allocation=20types"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3159f44d45e8ba65d22e5c543ddc8eaa7058ca44. --- cli/fleet.go | 12 ++++++------ cli/remote.go | 12 ++++++------ commands/remote.go | 10 +++++----- devices/common.go | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cli/fleet.go b/cli/fleet.go index 1c274ea..f386c36 100644 --- a/cli/fleet.go +++ b/cli/fleet.go @@ -79,27 +79,27 @@ Name supports wildcard prefix matching: } if result.IsAllocating() { - utils.Verbose("waiting for device allocation, session %s (0 seconds elapsed)", result.AllocationID) + utils.Verbose("waiting for device allocation, session %s (0 seconds elapsed)", result.SessionID) start := time.Now() deadline := start.Add(time.Duration(fleetTimeout) * time.Second) for { if time.Now().After(deadline) { - err := fmt.Errorf("timed out waiting for device allocation after %d seconds (session %s)", fleetTimeout, result.AllocationID) + err := fmt.Errorf("timed out waiting for device allocation after %d seconds (session %s)", fleetTimeout, result.SessionID) printJson(commands.NewErrorResponse(err)) return err } time.Sleep(5 * time.Second) elapsed := int(time.Since(start).Seconds()) - utils.Verbose("waiting for device allocation, session %s (%d seconds elapsed)", result.AllocationID, elapsed) - device, err := commands.FleetGetDeviceBySession(token, result.AllocationID) + utils.Verbose("waiting for device allocation, session %s (%d seconds elapsed)", result.SessionID, elapsed) + device, err := commands.FleetGetDeviceBySession(token, result.SessionID) if err != nil { - err = fmt.Errorf("failed to check device status (session %s): %w", result.AllocationID, err) + err = fmt.Errorf("failed to check device status (session %s): %w", result.SessionID, err) printJson(commands.NewErrorResponse(err)) return err } if device.State != "allocating" { response = commands.NewSuccessResponse(commands.FleetAllocateResponse{ - AllocationID: result.AllocationID, + SessionID: result.SessionID, ProvisionID: result.ProvisionID, State: device.State, Device: commands.FleetAllocateDevice{ diff --git a/cli/remote.go b/cli/remote.go index 9c6a171..df9836a 100644 --- a/cli/remote.go +++ b/cli/remote.go @@ -78,27 +78,27 @@ Name supports wildcard prefix matching: } if result.IsAllocating() { - utils.Verbose("waiting for device allocation, session %s (0 seconds elapsed)", result.AllocationID) + utils.Verbose("waiting for device allocation, session %s (0 seconds elapsed)", result.SessionID) start := time.Now() deadline := start.Add(time.Duration(fleetTimeout) * time.Second) for { if time.Now().After(deadline) { - err := fmt.Errorf("timed out waiting for device allocation after %d seconds (session %s)", fleetTimeout, result.AllocationID) + err := fmt.Errorf("timed out waiting for device allocation after %d seconds (session %s)", fleetTimeout, result.SessionID) printJson(commands.NewErrorResponse(err)) return err } time.Sleep(5 * time.Second) elapsed := int(time.Since(start).Seconds()) - utils.Verbose("waiting for device allocation, session %s (%d seconds elapsed)", result.AllocationID, elapsed) - device, err := commands.FleetGetDeviceBySession(token, result.AllocationID) + utils.Verbose("waiting for device allocation, session %s (%d seconds elapsed)", result.SessionID, elapsed) + device, err := commands.FleetGetDeviceBySession(token, result.SessionID) if err != nil { - err = fmt.Errorf("failed to check device status (session %s): %w", result.AllocationID, err) + err = fmt.Errorf("failed to check device status (session %s): %w", result.SessionID, err) printJson(commands.NewErrorResponse(err)) return err } if device.State != "allocating" { response = commands.NewSuccessResponse(commands.FleetAllocateResponse{ - AllocationID: result.AllocationID, + SessionID: result.SessionID, ProvisionID: result.ProvisionID, State: device.State, Device: commands.FleetAllocateDevice{ diff --git a/commands/remote.go b/commands/remote.go index b0f4873..9bbedc5 100644 --- a/commands/remote.go +++ b/commands/remote.go @@ -30,10 +30,10 @@ type FleetAllocateDevice struct { } type FleetAllocateResponse struct { - AllocationID string `json:"allocationId"` - Device FleetAllocateDevice `json:"device"` - ProvisionID string `json:"provisionId,omitempty"` - State string `json:"state,omitempty"` + SessionID string `json:"sessionId"` + Device FleetAllocateDevice `json:"device"` + ProvisionID string `json:"provisionId,omitempty"` + State string `json:"state,omitempty"` } func (r FleetAllocateResponse) IsAllocating() bool { @@ -93,7 +93,7 @@ func FleetGetDeviceBySession(token, sessionID string) (*devices.DeviceInfo, erro for _, d := range result.Devices { var p devices.DeviceProvider - if json.Unmarshal(d.Provider, &p) == nil && p.AllocationID == sessionID { + if json.Unmarshal(d.Provider, &p) == nil && p.SessionID == sessionID { return &d, nil } } diff --git a/devices/common.go b/devices/common.go index 36a13fb..b5a3639 100644 --- a/devices/common.go +++ b/devices/common.go @@ -271,7 +271,7 @@ type DeviceListOptions struct { type DeviceProvider struct { Type string `json:"type"` - AllocationID string `json:"allocationId,omitempty"` + SessionID string `json:"sessionId,omitempty"` } type DeviceInfo struct { From 3562af5bc654bbbea4e5d860346688fb36c9dcbd Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 30 Jun 2026 10:46:09 +0200 Subject: [PATCH 3/3] feat: add device.screencapture.setConfiguration RPC Adds a new `device.screencapture.setConfiguration` method that applies live encoder settings to an in-flight AVC capture without restarting the stream. Currently supports adjusting bitrate on the fly. --- devices/android.go | 20 ++++++ devices/avc_control.go | 48 +++++++++++++ devices/ios.go | 14 ++-- server/dispatch.go | 97 +++++++++++++------------- server/screencapture_setconfig_test.go | 22 ++++++ server/server.go | 31 ++++++++ 6 files changed, 179 insertions(+), 53 deletions(-) create mode 100644 devices/avc_control.go create mode 100644 server/screencapture_setconfig_test.go diff --git a/devices/android.go b/devices/android.go index 7651aa8..97b9263 100644 --- a/devices/android.go +++ b/devices/android.go @@ -6,6 +6,7 @@ import ( "encoding/json" "encoding/xml" "fmt" + "io" "os" "os/exec" "os/signal" @@ -1157,10 +1158,29 @@ func (d *AndroidDevice) StartScreenCapture(config ScreenCaptureConfig) error { return fmt.Errorf("failed to create stdout pipe: %v", err) } + // For AVC, keep stdin open as a live control channel: AvcServer reads + // newline-delimited "bitrate " commands so the host can adapt the + // encoder to the viewer's measured downlink without restarting the stream. + var stdin io.WriteCloser + if config.Format == "avc" { + stdin, err = cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to create stdin pipe: %v", err) + } + } + if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start %s: %v", serverClass, err) } + if config.Format == "avc" { + registerAvcControl(d.ID(), stdin) + defer func() { + unregisterAvcControl(d.ID()) + _ = stdin.Close() + }() + } + // Read bytes from the command output and send to callback buffer := make([]byte, 65536) for { diff --git a/devices/avc_control.go b/devices/avc_control.go new file mode 100644 index 0000000..bd689ff --- /dev/null +++ b/devices/avc_control.go @@ -0,0 +1,48 @@ +package devices + +import ( + "fmt" + "io" + "sync" +) + +// avcControls maps a device ID to the stdin writer of its running AvcServer +// process, so a separate JSON-RPC call (device.screencapture.setConfiguration) +// can push live encoder commands into an in-flight capture without restarting it. +// +// ponytail: one writer per device — a device streams at most one AVC capture at +// a time. Revisit if concurrent captures per device ever become a thing. +var ( + avcControlsMu sync.Mutex + avcControls = map[string]io.Writer{} +) + +func registerAvcControl(deviceID string, w io.Writer) { + avcControlsMu.Lock() + defer avcControlsMu.Unlock() + avcControls[deviceID] = w +} + +func unregisterAvcControl(deviceID string) { + avcControlsMu.Lock() + defer avcControlsMu.Unlock() + delete(avcControls, deviceID) +} + +// SetAvcBitrate pushes a live bitrate command to the running AvcServer for the +// given device. Returns an error if no AVC capture is currently active. +func SetAvcBitrate(deviceID string, bitrate int) error { + avcControlsMu.Lock() + w := avcControls[deviceID] + avcControlsMu.Unlock() + + if w == nil { + return fmt.Errorf("no active AVC capture for device %s", deviceID) + } + + // AvcServer reads newline-delimited commands from stdin. + if _, err := fmt.Fprintf(w, "bitrate %d\n", bitrate); err != nil { + return fmt.Errorf("failed to send bitrate command: %w", err) + } + return nil +} diff --git a/devices/ios.go b/devices/ios.go index f1e0b3a..be44b34 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -999,7 +999,7 @@ func (d *IOSDevice) StartScreenCapture(config ScreenCaptureConfig) error { // start DeviceKit // Note: passing nil registry since this is internal call from StartScreenCapture // ScreenCapture callers should have already registered the device via StartAgent - deviceKitInfo, err = d.StartDeviceKit(nil) + deviceKitInfo, err = d.StartDeviceKit264(nil) if err != nil { return fmt.Errorf("failed to start DeviceKit: %w", err) } @@ -1417,10 +1417,10 @@ func (d *IOSDevice) isDeviceKitRunning() bool { return true } -// StartDeviceKit starts the devicekit-ios XCUITest which provides: +// StartDeviceKit264 starts the devicekit-ios XCUITest which provides: // - An HTTP server for tap/dumpUI commands (port 12004) // - A broadcast extension for H.264 screen streaming (port 12005) -func (d *IOSDevice) StartDeviceKit(hook *ShutdownHook) (*DeviceKitInfo, error) { +func (d *IOSDevice) StartDeviceKit264(hook *ShutdownHook) (*DeviceKitInfo, error) { // register cleanup hook for this device if hook != nil { hookName := fmt.Sprintf("ios-devicekit-%s", d.Udid) @@ -1444,8 +1444,12 @@ func (d *IOSDevice) StartDeviceKit(hook *ShutdownHook) (*DeviceKitInfo, error) { var devicekitMainAppBundleId string for _, app := range apps { - // look for the main app, not the test runner - if strings.HasPrefix(app.PackageName, "com.") && strings.Contains(app.PackageName, "devicekit-ios") && !strings.Contains(app.PackageName, "UITests") { + // look for the main app, not the test runner. the bundle id can carry a + // signing/team prefix when re-signed (e.g. com.megidish1.devicekit-ios), so + // match on suffix rather than substring. the runner ends with + // "...devicekit-iosUITests.xctrunner" and is excluded by this suffix. + fmt.Println("-> %s", app.PackageName) + if strings.HasSuffix(app.PackageName, "devicekit-h264") { utils.Verbose("DeviceKit main app found, bundle ID: %s", app.PackageName) devicekitMainAppBundleId = app.PackageName break diff --git a/server/dispatch.go b/server/dispatch.go index 49d4571..2405264 100644 --- a/server/dispatch.go +++ b/server/dispatch.go @@ -12,54 +12,55 @@ type HandlerFunc func(params json.RawMessage) (any, error) // This is used by both the HTTP server and embedded clients func GetMethodRegistry() map[string]HandlerFunc { return map[string]HandlerFunc{ - "devices.list": handleDevicesList, - "device.screenshot": handleScreenshot, - "device.screencapture": handleScreenCaptureSession, - "device.io.tap": handleIoTap, - "device.io.longpress": handleIoLongPress, - "device.io.text": handleIoText, - "device.io.keys": handleIoKeys, - "device.io.button": handleIoButton, - "device.io.swipe": handleIoSwipe, - "device.io.gesture": handleIoGesture, - "device.url": handleURL, - "device.info": handleDeviceInfo, - "device.io.orientation.get": handleIoOrientationGet, - "device.io.orientation.set": handleIoOrientationSet, - "device.boot": handleDeviceBoot, - "device.shutdown": handleDeviceShutdown, - "device.reboot": handleDeviceReboot, - "device.settings.apply": handleSettingsApply, - "device.dump.ui": handleDumpUI, - "device.apps.launch": handleAppsLaunch, - "device.apps.terminate": handleAppsTerminate, - "device.apps.list": handleAppsList, - "device.apps.foreground": handleAppsForeground, - "device.apps.install": handleAppsInstall, - "device.apps.uninstall": handleAppsUninstall, - "device.screenrecord": handleScreenRecord, - "device.screenrecord.stop": handleScreenRecordStop, - "device.crashes.list": handleCrashesList, - "device.crashes.get": handleCrashesGet, - "device.webview.list": handleWebViewList, - "device.webview.content": handleWebViewContent, - "device.webview.goto": handleWebViewGoto, - "device.webview.reload": handleWebViewReload, - "device.webview.goBack": handleWebViewGoBack, - "device.webview.goForward": handleWebViewGoForward, - "device.webview.url": handleWebViewURL, - "device.webview.title": handleWebViewTitle, - "device.webview.query": handleWebViewQuery, - "device.webview.evaluate": handleWebViewEvaluate, - "device.webview.waitForLoadState": handleWebViewWaitForLoadState, - "server.info": handleServerInfo, - "server.shutdown": handleServerShutdown, - "device.apps.path": handleAppsPath, - "device.fs.ls": handleFsLs, - "device.fs.pull": handleFsPull, - "device.fs.push": handleFsPush, - "device.fs.mkdir": handleFsMkdir, - "device.fs.rm": handleFsRm, + "devices.list": handleDevicesList, + "device.screenshot": handleScreenshot, + "device.screencapture": handleScreenCaptureSession, + "device.screencapture.setConfiguration": handleScreenCaptureSetConfiguration, + "device.io.tap": handleIoTap, + "device.io.longpress": handleIoLongPress, + "device.io.text": handleIoText, + "device.io.keys": handleIoKeys, + "device.io.button": handleIoButton, + "device.io.swipe": handleIoSwipe, + "device.io.gesture": handleIoGesture, + "device.url": handleURL, + "device.info": handleDeviceInfo, + "device.io.orientation.get": handleIoOrientationGet, + "device.io.orientation.set": handleIoOrientationSet, + "device.boot": handleDeviceBoot, + "device.shutdown": handleDeviceShutdown, + "device.reboot": handleDeviceReboot, + "device.settings.apply": handleSettingsApply, + "device.dump.ui": handleDumpUI, + "device.apps.launch": handleAppsLaunch, + "device.apps.terminate": handleAppsTerminate, + "device.apps.list": handleAppsList, + "device.apps.foreground": handleAppsForeground, + "device.apps.install": handleAppsInstall, + "device.apps.uninstall": handleAppsUninstall, + "device.screenrecord": handleScreenRecord, + "device.screenrecord.stop": handleScreenRecordStop, + "device.crashes.list": handleCrashesList, + "device.crashes.get": handleCrashesGet, + "device.webview.list": handleWebViewList, + "device.webview.content": handleWebViewContent, + "device.webview.goto": handleWebViewGoto, + "device.webview.reload": handleWebViewReload, + "device.webview.goBack": handleWebViewGoBack, + "device.webview.goForward": handleWebViewGoForward, + "device.webview.url": handleWebViewURL, + "device.webview.title": handleWebViewTitle, + "device.webview.query": handleWebViewQuery, + "device.webview.evaluate": handleWebViewEvaluate, + "device.webview.waitForLoadState": handleWebViewWaitForLoadState, + "server.info": handleServerInfo, + "server.shutdown": handleServerShutdown, + "device.apps.path": handleAppsPath, + "device.fs.ls": handleFsLs, + "device.fs.pull": handleFsPull, + "device.fs.push": handleFsPush, + "device.fs.mkdir": handleFsMkdir, + "device.fs.rm": handleFsRm, } } diff --git a/server/screencapture_setconfig_test.go b/server/screencapture_setconfig_test.go new file mode 100644 index 0000000..af6835f --- /dev/null +++ b/server/screencapture_setconfig_test.go @@ -0,0 +1,22 @@ +package server + +import ( + "encoding/json" + "strings" + "testing" +) + +// rejects bad input before any device lookup, so these run without a device. +func TestSetConfigurationRejectsNonPositiveBitrate(t *testing.T) { + _, err := handleScreenCaptureSetConfiguration(json.RawMessage(`{"deviceId":"x","bitrate":0}`)) + if err == nil || !strings.Contains(err.Error(), "bitrate must be positive") { + t.Fatalf("expected positive-bitrate error, got: %v", err) + } +} + +func TestSetConfigurationRejectsInvalidJSON(t *testing.T) { + _, err := handleScreenCaptureSetConfiguration(json.RawMessage(`not json`)) + if err == nil || !strings.Contains(err.Error(), "invalid parameters") { + t.Fatalf("expected invalid-parameters error, got: %v", err) + } +} diff --git a/server/server.go b/server/server.go index ba0c922..37d7c0b 100644 --- a/server/server.go +++ b/server/server.go @@ -1330,6 +1330,37 @@ func handleScreenCaptureSession(params json.RawMessage) (any, error) { return result, nil } +// screenCaptureSetConfigRequest is the parameter shape for the +// device.screencapture.setConfiguration RPC. +type screenCaptureSetConfigRequest struct { + DeviceID string `json:"deviceId"` + Bitrate int `json:"bitrate"` +} + +// handleScreenCaptureSetConfiguration applies live encoder settings to an +// in-flight AVC capture (currently only bitrate) without restarting the stream. +func handleScreenCaptureSetConfiguration(params json.RawMessage) (any, error) { + var req screenCaptureSetConfigRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid parameters: %w", err) + } + + if req.Bitrate <= 0 { + return nil, fmt.Errorf("bitrate must be positive") + } + + targetDevice, err := commands.FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return nil, fmt.Errorf("error finding device: %w", err) + } + + if err := devices.SetAvcBitrate(targetDevice.ID(), req.Bitrate); err != nil { + return nil, err + } + + return map[string]any{"deviceId": targetDevice.ID(), "bitrate": req.Bitrate}, nil +} + // handleStream handles the /stream endpoint for screen capture streaming func handleStream(w http.ResponseWriter, r *http.Request) { // extract session ID from query parameter