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