devices.go•74.6 kB
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"github.com/babelcloud/gbox/packages/cli/internal/cloud"
"github.com/babelcloud/gbox/packages/cli/internal/device"
"github.com/babelcloud/gbox/packages/cli/internal/device_connect/control"
"github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/audio"
"github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/h264"
"github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/stream"
serverScripts "github.com/babelcloud/gbox/packages/cli/internal/server/scripts"
"github.com/babelcloud/gbox/packages/cli/internal/util"
"github.com/gorilla/websocket"
"github.com/pkg/errors"
)
// pathParam retrieves a path parameter from the request context
// This is a local copy to avoid import cycle with router package
func pathParam(r *http.Request, key string) string {
// Use the same context key format as PatternRouter
contextKey := "gbox-pattern-router:" + key
if val := r.Context().Value(contextKey); val != nil {
if str, ok := val.(string); ok {
return str
}
}
return ""
}
// DeviceDTO is a strong-typed representation of a device for API responses
type DeviceDTO struct {
ID string `json:"id"`
TransportID string `json:"transportId"`
Serialno string `json:"serialno"`
Platform string `json:"platform"` // mobile, desktop
OS string `json:"os"` // android, linux, windows, macos
DeviceType string `json:"deviceType"` // physical, emulator, vm
IsRegistered bool `json:"isRegistered"`
IsConnected bool `json:"isConnected"` // true if device is currently connected to AP
IsReconnecting bool `json:"isReconnecting"` // true if device is attempting to reconnect
ReconnectAttempt int `json:"reconnectAttempt"` // Current reconnection attempt count
ReconnectMaxRetry int `json:"reconnectMaxRetry"` // Maximum reconnection attempts
RegId string `json:"regId"`
IsLocal bool `json:"isLocal"` // true if this is the local desktop device
Metadata map[string]interface{} `json:"metadata"` // Device-specific metadata
}
// setWebMStreamingHeaders sets HTTP headers for WebM audio streaming
func setWebMStreamingHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "audio/webm; codecs=opus")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Transfer-Encoding", "chunked")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Range")
}
// setRawOpusStreamingHeaders sets HTTP headers for raw Opus audio streaming
func setRawOpusStreamingHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "audio/opus")
w.Header().Set("Transfer-Encoding", "chunked")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
}
// startStreamingResponse sets headers and starts the streaming response
func startStreamingResponse(w http.ResponseWriter) {
w.WriteHeader(http.StatusOK)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
var controlUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for now
},
}
// DeviceHandlers contains handlers for device management
type DeviceHandlers struct {
serverService ServerService
upgrader websocket.Upgrader
webrtcHandlers *WebRTCHandlers
deviceManager device.DeviceManager
}
// NewDeviceHandlers creates a new device handlers instance
func NewDeviceHandlers(serverSvc ServerService) *DeviceHandlers {
return &DeviceHandlers{
serverService: serverSvc,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for now
},
},
webrtcHandlers: NewWebRTCHandlers(serverSvc),
deviceManager: device.NewManager("android"),
}
}
// HandleDeviceList handles device listing requests
func (h *DeviceHandlers) HandleDeviceList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Use unified device manager (reused)
devs, err := h.deviceManager.GetDevices()
if err != nil {
log.Printf("Failed to get devices: %v", err)
RespondJSON(w, http.StatusInternalServerError, map[string]interface{}{
"success": false,
"error": err.Error(),
"devices": []interface{}{},
})
return
}
deviceAPI := cloud.NewDeviceAPI()
// Get all registered devices from cloud in one call
registeredDevicesMap := make(map[string]*cloud.Device)
allCloudDevices, err := deviceAPI.GetAll()
if err != nil {
log.Printf("Failed to get all devices from cloud: %v", err)
} else {
// Build a map of regId -> Device for quick lookup
for _, cloudDevice := range allCloudDevices.Data {
if cloudDevice.RegId != "" {
registeredDevicesMap[cloudDevice.RegId] = cloudDevice
}
}
}
dtos := make([]DeviceDTO, 0, len(devs))
// Add Android devices
for _, d := range devs {
// Get device information for Android device
androidMgr := device.NewManager("android")
width, height, resErr := androidMgr.GetDisplayResolution(d.ID)
displayResolution := ""
if resErr == nil {
displayResolution = fmt.Sprintf("%dx%d", width, height)
}
osVersion, _ := androidMgr.GetOSVersion(d.ID) // non-fatal
memory, _ := androidMgr.GetMemory(d.ID) // non-fatal
// Build Android-specific metadata
metadata := make(map[string]interface{})
metadata["model"] = d.Model
metadata["manufacturer"] = d.Manufacturer
metadata["connectionType"] = d.ConnectionType
metadata["androidId"] = d.AndroidID // Android-specific field
if displayResolution != "" {
metadata["resolution"] = displayResolution
}
if osVersion != "" {
metadata["osVersion"] = osVersion
}
if memory != "" {
metadata["memory"] = memory
}
dto := DeviceDTO{
ID: "",
TransportID: d.ID,
Serialno: d.SerialNo,
Platform: "mobile", // Android devices are mobile
OS: "android", // Android devices
DeviceType: util.DetectAndroidDeviceType(d.ID, d.SerialNo),
IsRegistered: false,
RegId: d.RegId,
Metadata: metadata,
}
// Check if device is registered by looking up in the map
if strings.TrimSpace(d.RegId) != "" {
if cloudDevice, found := registeredDevicesMap[d.RegId]; found {
dto.IsRegistered = true
dto.ID = cloudDevice.Id
// Update Platform and OS from cloud device metadata if available
if cloudDevice.Metadata.DeviceType != "" {
dto.Platform = cloudDevice.Metadata.DeviceType
}
if cloudDevice.Metadata.OsType != "" {
dto.OS = cloudDevice.Metadata.OsType
}
}
}
// Check if device is currently connected to AP
dto.IsConnected = h.serverService.IsDeviceConnected(d.SerialNo)
// Check reconnection state
if reconnectState := h.serverService.GetDeviceReconnectState(d.SerialNo); reconnectState != nil {
// Use type assertion with interface{} to avoid circular dependency
if stateMap, ok := reconnectState.(map[string]interface{}); ok {
if isReconnecting, ok := stateMap["isReconnecting"].(bool); ok {
dto.IsReconnecting = isReconnecting
}
if attempt, ok := stateMap["attempt"].(int); ok {
dto.ReconnectAttempt = attempt
}
if maxRetry, ok := stateMap["maxRetry"].(int); ok {
dto.ReconnectMaxRetry = maxRetry
}
}
}
// Update device info cache with complete device information
h.serverService.UpdateDeviceInfo(&dto)
dtos = append(dtos, dto)
}
// Add desktop device (always show local machine)
// Map runtime.GOOS to osType for device manager
var osType string
switch runtime.GOOS {
case "darwin":
osType = "macos"
case "linux", "windows":
osType = strings.ToLower(runtime.GOOS)
default:
osType = "linux" // Default fallback
}
desktopMgr := device.NewManager(osType)
localRegId, _ := desktopMgr.GetRegId("") // non-fatal
serialno := util.GetDesktopSerialNo(osType)
// Get device information for desktop device
width, height, resErr := desktopMgr.GetDisplayResolution("")
displayResolution := ""
if resErr == nil {
displayResolution = fmt.Sprintf("%dx%d", width, height)
}
osVersion, _ := desktopMgr.GetOSVersion("") // non-fatal
memory, _ := desktopMgr.GetMemory("") // non-fatal
// Build desktop-specific metadata
metadata := make(map[string]interface{})
if osType == "macos" {
metadata["chip"] = util.GetMacOSChip() // macOS-specific field
}
if osVersion != "" {
metadata["osVersion"] = osVersion
}
if memory != "" {
metadata["memory"] = memory
}
if displayResolution != "" {
metadata["resolution"] = displayResolution
}
// Add hostname for desktop devices
hostname, err := os.Hostname()
if err == nil && hostname != "" {
metadata["hostname"] = hostname
}
var desktopDTO DeviceDTO
deviceType := util.DetectDesktopDeviceType(osType)
if err == nil && localRegId != "" {
// Check if this desktop device is registered in cloud
if cloudDevice, found := registeredDevicesMap[localRegId]; found {
// Desktop device is registered
desktopDTO = DeviceDTO{
ID: cloudDevice.Id,
TransportID: "local",
Serialno: cloudDevice.Metadata.Serialno,
Platform: "desktop",
OS: osType,
DeviceType: deviceType,
IsRegistered: true,
RegId: localRegId,
IsLocal: true,
Metadata: metadata,
}
// Update Platform and OS from cloud device metadata if available
if cloudDevice.Metadata.DeviceType != "" {
desktopDTO.Platform = cloudDevice.Metadata.DeviceType
}
if cloudDevice.Metadata.OsType != "" {
desktopDTO.OS = cloudDevice.Metadata.OsType
}
} else {
// Desktop device exists locally but not registered
desktopDTO = DeviceDTO{
ID: "",
TransportID: "local",
Serialno: serialno,
Platform: "desktop",
OS: osType,
DeviceType: deviceType,
IsRegistered: false,
RegId: localRegId,
IsLocal: true,
Metadata: metadata,
}
}
} else {
// No local regId, show desktop device as unregistered
desktopDTO = DeviceDTO{
ID: "",
TransportID: "local",
Serialno: serialno,
Platform: "desktop",
OS: osType,
DeviceType: deviceType,
IsRegistered: false,
RegId: "",
IsLocal: true,
Metadata: metadata,
}
}
// Check if desktop device is currently connected to AP
desktopDTO.IsConnected = h.serverService.IsDeviceConnected(desktopDTO.Serialno)
// Check reconnection state for desktop device
if reconnectState := h.serverService.GetDeviceReconnectState(desktopDTO.Serialno); reconnectState != nil {
if stateMap, ok := reconnectState.(map[string]interface{}); ok {
if isReconnecting, ok := stateMap["isReconnecting"].(bool); ok {
desktopDTO.IsReconnecting = isReconnecting
}
if attempt, ok := stateMap["attempt"].(int); ok {
desktopDTO.ReconnectAttempt = attempt
}
if maxRetry, ok := stateMap["maxRetry"].(int); ok {
desktopDTO.ReconnectMaxRetry = maxRetry
}
}
}
// Update device info cache with complete desktop device information
h.serverService.UpdateDeviceInfo(&desktopDTO)
dtos = append(dtos, desktopDTO)
RespondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"devices": dtos,
"onDemandEnabled": true,
})
}
// HandleDeviceAction handles device action requests (connect/disconnect)
func (h *DeviceHandlers) HandleDeviceAction(w http.ResponseWriter, r *http.Request) {
// Extract device serial from path: /api/devices/{serial}
path := strings.TrimPrefix(r.URL.Path, "/api/devices/")
deviceID := strings.Split(path, "/")[0]
if deviceID == "" {
http.Error(w, "Device serial required", http.StatusBadRequest)
return
}
// Handle connect/disconnect based on HTTP method
switch r.Method {
case http.MethodPost:
// POST /api/devices/{serial} - connect device
h.handleDeviceConnect(w, r, deviceID)
case http.MethodDelete:
// DELETE /api/devices/{serial} - disconnect device
h.handleDeviceDisconnect(w, r, deviceID)
default:
RespondJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
"success": false,
"error": "Method not allowed. Use POST to connect or DELETE to disconnect",
})
}
}
// validateDeviceTypeAndOsType validates deviceType and osType parameters and their combination.
// Returns normalized osType (with default value applied) and error if validation fails.
func validateDeviceTypeAndOsType(deviceType, osType string) (string, error) {
// Validate deviceType
if deviceType != "mobile" && deviceType != "desktop" {
return "", fmt.Errorf("invalid deviceType: %s, must be 'mobile' or 'desktop'", deviceType)
}
// Validate osType based on deviceType
switch deviceType {
case "mobile":
// Mobile devices only support android
if osType != "" && osType != "android" {
return "", fmt.Errorf("mobile device type only supports 'android' osType, got: %s", osType)
}
// Default to android if not specified
if osType == "" {
osType = "android"
}
case "desktop":
// Desktop devices support linux, windows, macos
validDesktopOsTypes := map[string]bool{
"linux": true,
"windows": true,
"macos": true,
}
if osType != "" && !validDesktopOsTypes[osType] {
return "", fmt.Errorf("desktop device type only supports 'linux', 'windows', or 'macos' osType, got: %s", osType)
}
default:
return "", fmt.Errorf("invalid deviceType: %s, must be 'mobile' or 'desktop'", deviceType)
}
return osType, nil
}
// HandleDeviceRegister handles device registration requests
func (h *DeviceHandlers) HandleDeviceRegister(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var reqBody struct {
DeviceId string `json:"deviceId"`
DeviceType string `json:"deviceType"` // mobile, desktop
OsType string `json:"osType"` // android, linux, windows, macos
}
if err := decoder.Decode(&reqBody); err != nil {
http.Error(w, errors.Wrap(err, "failed to parse request body").Error(), http.StatusBadRequest)
return
}
// Normalize deviceType and osType
deviceType := strings.ToLower(reqBody.DeviceType)
osType := strings.ToLower(reqBody.OsType)
// Validate deviceType and osType, and get normalized osType
normalizedOsType, err := validateDeviceTypeAndOsType(deviceType, osType)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
osType = normalizedOsType
deviceAPI := cloud.NewDeviceAPI()
created, err := h.registerDevice(deviceAPI, reqBody.DeviceId, deviceType, osType)
if err != nil {
// Check if it's a validation error (400) or server error (500)
if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "only supports") {
http.Error(w, err.Error(), http.StatusBadRequest)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
// Connect to access point asynchronously (for both desktop and mobile)
go func() {
if err := h.serverService.ConnectAP(created.Id); err != nil {
log.Print(errors.Wrapf(err, "fail to connect device %s to access point", created.Id))
}
}()
// Return response
RespondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"data": created,
})
}
// registerDevice registers a device based on deviceType and osType
func (h *DeviceHandlers) registerDevice(deviceAPI *cloud.DeviceAPI, deviceId, deviceType, osType string) (*cloud.Device, error) {
// Initialize xdotool and noVNC environments for Linux desktop devices
if deviceType == "desktop" && osType == "linux" {
if err := runLinuxInitXdotoolScript(); err != nil {
log.Printf("Warning: init-linux-xdotool script failed: %v", err)
}
if err := runLinuxInitNoVncScript(); err != nil {
log.Printf("Warning: init-linux-novnc script failed: %v", err)
}
}
// Prepare device metadata based on device type
var serialno, androidId, regId string
var err error
metadata := make(map[string]interface{})
if deviceType == "desktop" {
// Desktop device: get serialno from system
serialno = util.GetDesktopSerialNo(osType)
// Try to read existing regId from local file using DesktopManager
desktopMgr := device.NewManager(osType)
if regIdFromFile, readErr := desktopMgr.GetRegId(""); readErr == nil && regIdFromFile != "" {
regId = regIdFromFile
}
// Get device information for desktop device
width, height, resErr := desktopMgr.GetDisplayResolution("")
displayResolution := ""
if resErr == nil {
displayResolution = fmt.Sprintf("%dx%d", width, height)
}
osVersion, _ := desktopMgr.GetOSVersion("") // non-fatal
memory, _ := desktopMgr.GetMemory("") // non-fatal
// Build desktop-specific metadata
if osType == "macos" {
metadata["chip"] = util.GetMacOSChip() // macOS-specific field
}
if osVersion != "" {
metadata["osVersion"] = osVersion
}
if memory != "" {
metadata["memory"] = memory
}
if displayResolution != "" {
metadata["resolution"] = displayResolution
}
// Add hostname for desktop devices
hostname, hostnameErr := os.Hostname()
if hostnameErr == nil && hostname != "" {
metadata["hostname"] = hostname
}
} else {
// Mobile device: get identifiers from ADB
devMgr := device.NewManager("android")
ids, err := devMgr.GetIdentifiers(deviceId)
if err != nil {
return nil, errors.Wrap(err, "failed to resolve device serialno or android_id")
}
serialno = ids.SerialNo
if ids.AndroidID != nil {
androidId = *ids.AndroidID
metadata["androidId"] = androidId
}
regId = ids.RegId
// Get device information for Android device
width, height, resErr := devMgr.GetDisplayResolution(deviceId)
displayResolution := ""
if resErr == nil {
displayResolution = fmt.Sprintf("%dx%d", width, height)
}
osVersion, _ := devMgr.GetOSVersion(deviceId) // non-fatal
memory, _ := devMgr.GetMemory(deviceId) // non-fatal
// Get device info to get model, manufacturer, connectionType
devices, err := devMgr.GetDevices()
if err == nil {
for _, d := range devices {
if d.ID == deviceId {
if d.Model != "" {
metadata["model"] = d.Model
}
if d.Manufacturer != "" {
metadata["manufacturer"] = d.Manufacturer
}
if d.ConnectionType != "" {
metadata["connectionType"] = d.ConnectionType
}
break
}
}
}
// Build Android-specific metadata
if displayResolution != "" {
metadata["resolution"] = displayResolution
}
if osVersion != "" {
metadata["osVersion"] = osVersion
}
if memory != "" {
metadata["memory"] = memory
}
}
// Extract fields from metadata map
resolution := ""
if res, ok := metadata["resolution"].(string); ok {
resolution = res
}
hostname := ""
if hn, ok := metadata["hostname"].(string); ok {
hostname = hn
}
chip := ""
if c, ok := metadata["chip"].(string); ok {
chip = c
}
osVersion := ""
if ov, ok := metadata["osVersion"].(string); ok {
osVersion = ov
}
memory := ""
if m, ok := metadata["memory"].(string); ok {
memory = m
}
model := ""
if m, ok := metadata["model"].(string); ok {
model = m
}
manufacturer := ""
if m, ok := metadata["manufacturer"].(string); ok {
manufacturer = m
}
connectionType := ""
if ct, ok := metadata["connectionType"].(string); ok {
connectionType = ct
}
// Create device in cloud
newDevice := &cloud.Device{
Metadata: struct {
Serialno string `json:"serialno,omitempty"`
AndroidId string `json:"androidId,omitempty"`
Type string `json:"type,omitempty"`
DeviceType string `json:"deviceType,omitempty"`
OsType string `json:"osType,omitempty"`
Resolution string `json:"resolution,omitempty"`
Hostname string `json:"hostname,omitempty"`
Chip string `json:"chip,omitempty"`
OsVersion string `json:"osVersion,omitempty"`
Memory string `json:"memory,omitempty"`
Model string `json:"model,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
ConnectionType string `json:"connectionType,omitempty"`
}{
Serialno: serialno,
AndroidId: androidId,
Type: osType, // Set Type field for backward compatibility with remote API
DeviceType: deviceType,
OsType: osType,
Resolution: resolution,
Hostname: hostname,
Chip: chip,
OsVersion: osVersion,
Memory: memory,
Model: model,
Manufacturer: manufacturer,
ConnectionType: connectionType,
},
RegId: regId,
}
created, err := deviceAPI.Create(newDevice)
if err != nil {
return nil, errors.Wrap(err, "failed to register device")
}
// Persist the created device RegId back to the device
if created != nil && created.RegId != "" {
var devMgr device.DeviceManager
if deviceType == "desktop" {
devMgr = device.NewManager(osType)
} else {
devMgr = device.NewManager("android")
}
if err := devMgr.SetRegId(deviceId, created.RegId); err != nil {
log.Printf("Warning: failed to persist RegId to device %s: %v", deviceId, err)
}
}
return created, nil
}
// HandleDeviceUnregister handles device unregistration requests
func (h *DeviceHandlers) HandleDeviceUnregister(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var reqBody struct {
DeviceId string `json:"deviceId"`
}
if err := decoder.Decode(&reqBody); err != nil {
http.Error(w, errors.Wrap(err, "failed to parse request body").Error(), http.StatusBadRequest)
return
}
deviceAPI := cloud.NewDeviceAPI()
// First, try to find device by regId (works for both Android and Desktop devices)
// If reqBody.DeviceId looks like a UUID/regId, try to find device by regId first
deviceList, err := deviceAPI.GetByRegId(reqBody.DeviceId)
if err == nil && len(deviceList.Data) > 0 {
// Found device(s) by regId, delete them
for _, device := range deviceList.Data {
if err := deviceAPI.Delete(device.Id); err != nil {
http.Error(w, errors.Wrapf(err, "failed to delete device %s", device.Id).Error(), http.StatusInternalServerError)
return
}
}
// Successfully deleted by regId, return early
go func() {
if err := h.serverService.DisconnectAP(reqBody.DeviceId); err != nil {
log.Print(errors.Wrapf(err, "fail to disconnect device %s from access point", reqBody.DeviceId))
}
}()
RespondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
})
return
}
// If not found by regId, try to resolve as Android device (for backward compatibility)
devMgr2 := device.NewManager("android")
ids2, err := devMgr2.GetIdentifiers(reqBody.DeviceId)
if err != nil {
// If GetIdentifiers fails, it might be a desktop device or invalid deviceId
// Try one more time to find by regId (in case it's a regId that wasn't found above)
deviceList, err2 := deviceAPI.GetByRegId(reqBody.DeviceId)
if err2 == nil && len(deviceList.Data) > 0 {
for _, device := range deviceList.Data {
if err := deviceAPI.Delete(device.Id); err != nil {
http.Error(w, errors.Wrapf(err, "failed to delete device %s", device.Id).Error(), http.StatusInternalServerError)
return
}
}
go func() {
if err := h.serverService.DisconnectAP(reqBody.DeviceId); err != nil {
log.Print(errors.Wrapf(err, "fail to disconnect device %s from access point", reqBody.DeviceId))
}
}()
RespondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
})
return
}
http.Error(w, errors.Wrap(err, "failed to resolve device identifiers").Error(), http.StatusInternalServerError)
return
}
serialno := ids2.SerialNo
var androidId string
if ids2.AndroidID != nil {
androidId = *ids2.AndroidID
}
regId := ids2.RegId
// If regId is available, use it to find and delete the device (most accurate)
if regId != "" {
deviceList, err := deviceAPI.GetByRegId(regId)
if err != nil {
// If lookup by regId fails, fallback to serialno/androidId lookup
log.Printf("Warning: failed to get devices by regId %s: %v, falling back to serialno/androidId lookup", regId, err)
} else if len(deviceList.Data) > 0 {
// Found device(s) by regId, delete them
for _, device := range deviceList.Data {
if err := deviceAPI.Delete(device.Id); err != nil {
http.Error(w, errors.Wrapf(err, "failed to delete device %s", device.Id).Error(), http.StatusInternalServerError)
return
}
}
// Successfully deleted by regId, return early
go func() {
if err := h.serverService.DisconnectAP(reqBody.DeviceId); err != nil {
log.Print(errors.Wrapf(err, "fail to disconnect device %s from access point", reqBody.DeviceId))
}
}()
RespondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
})
return
}
}
// Fallback: use serialno and androidId to find and delete devices (Android only)
deviceList, err = deviceAPI.GetBySerialnoAndAndroidId(serialno, androidId)
if err != nil {
http.Error(w, errors.Wrap(err, "failed to get devices").Error(), http.StatusInternalServerError)
return
}
if len(deviceList.Data) > 0 {
for _, device := range deviceList.Data {
if err := deviceAPI.Delete(device.Id); err != nil {
http.Error(w, errors.Wrapf(err, "failed to delete device %s", device.Id).Error(), http.StatusInternalServerError)
return
}
}
} else {
// No devices found by serialno/androidId either
if regId != "" {
http.Error(w, fmt.Errorf("device not found by regId %s or serialno/androidId", regId).Error(), http.StatusNotFound)
return
}
http.Error(w, fmt.Errorf("device not found").Error(), http.StatusNotFound)
return
}
go func() {
if err := h.serverService.DisconnectAP(reqBody.DeviceId); err != nil {
log.Print(errors.Wrapf(err, "fail to disconnect device %s from access point", reqBody.DeviceId))
}
}()
RespondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
})
}
// Device streaming handlers
func (h *DeviceHandlers) HandleDeviceVideo(w http.ResponseWriter, req *http.Request) {
// Extract device serial from path: /api/devices/{serial}/video
path := strings.TrimPrefix(req.URL.Path, "/api/devices/")
parts := strings.Split(path, "/")
deviceSerial := parts[0]
if strings.Contains(req.Header.Get("via"), "gbox-device-ap") {
deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial)
}
if deviceSerial == "" {
http.Error(w, "Device serial required", http.StatusBadRequest)
return
}
// Parse mode and format parameters
query := req.URL.Query()
codec := query.Get("codec")
format := query.Get("format")
mode := query.Get("mode") // Check if mode is already provided
// Set mode based on codec and format, or use existing mode
if mode == "" {
if codec == "h264" {
if format == "avc" {
mode = "h264"
format = "avc"
} else if format == "annexb" {
mode = "h264"
format = "annexb"
} else if format == "webm" {
mode = "webm"
} else {
mode = "h264" // default to annexb
}
} else {
mode = "h264" // default
}
}
log.Printf("[HandleDeviceVideo] Processing video request for device: %s, mode: %s, format: %s", deviceSerial, mode, format)
// Direct video streaming implementation
switch mode {
case "h264":
// Check format parameter for AVC vs Annex-B
if format == "avc" {
// AVC format H.264 streaming (for WebCodecs)
handler := h264.NewAVCHTTPHandler(deviceSerial)
handler.ServeHTTP(w, req)
} else {
// Direct H.264 streaming (Annex-B format)
handler := h264.NewAnnexBHandler(deviceSerial)
handler.ServeHTTP(w, req)
}
case "webm":
// WebM container streaming with H.264 video and Opus audio
h.HandleWebMStream(w, req, deviceSerial)
case "mp4":
// MP4 container streaming with H.264 video and Opus audio
h.HandleMP4Stream(w, req, deviceSerial)
default:
http.Error(w, "Invalid mode. Supported: h264, webm, mp4", http.StatusBadRequest)
}
}
func (h *DeviceHandlers) HandleDeviceAudio(w http.ResponseWriter, req *http.Request) {
// Extract device serial from path: /api/devices/{serial}/audio
path := strings.TrimPrefix(req.URL.Path, "/api/devices/")
parts := strings.Split(path, "/")
deviceSerial := parts[0]
log.Printf("[HandleDeviceAudio] Processing audio request for device: %s, URL: %s", deviceSerial, req.URL.String())
if strings.Contains(req.Header.Get("via"), "gbox-device-ap") {
deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial)
}
if deviceSerial == "" {
http.Error(w, "Device serial required", http.StatusBadRequest)
return
}
if !isValidDeviceSerial(deviceSerial) {
http.Error(w, "Invalid device serial", http.StatusBadRequest)
return
}
// Parse codec/format parameters
codec := req.URL.Query().Get("codec")
if codec == "" {
codec = "aac" // default to AAC
}
format := req.URL.Query().Get("format")
log.Printf("[HandleDeviceAudio] Audio parameters - codec: %s, format: %s", codec, format)
// Direct audio streaming implementation
switch codec {
case "opus":
// Determine streaming format and setup
audioService := audio.GetAudioService()
if format == "webm" {
// WebM container streaming
log.Printf("[HandleDeviceAudio] Using WebM streaming for device: %s", deviceSerial)
setWebMStreamingHeaders(w)
startStreamingResponse(w)
if err := audioService.StreamWebM(deviceSerial, w, req); err != nil {
log.Printf("[HandleDeviceAudio] WebM streaming error: %v", err)
http.Error(w, "WebM streaming failed", http.StatusInternalServerError)
}
return
}
// Raw Opus streaming
log.Printf("[HandleDeviceAudio] Using raw Opus streaming for device: %s", deviceSerial)
setRawOpusStreamingHeaders(w)
startStreamingResponse(w)
audioService.StreamOpus(deviceSerial, w)
case "aac":
// AAC dump: format=raw (default) or format=adts
withADTS := strings.ToLower(format) == "adts"
// Ensure scrcpy source in mp4 mode to use AAC encoder
// This is handled by the audio service
log.Printf("[HandleDeviceAudio] Using AAC streaming for device: %s, withADTS: %v", deviceSerial, withADTS)
// Set appropriate headers for AAC streaming
if withADTS {
w.Header().Set("Content-Type", "audio/aac")
} else {
w.Header().Set("Content-Type", "audio/aac")
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
startStreamingResponse(w)
// Note: AAC streaming is not implemented in the current audio service
// This would need to be implemented if AAC streaming is required
http.Error(w, "AAC streaming not implemented", http.StatusNotImplemented)
default:
http.Error(w, "Invalid codec. Supported: opus, aac", http.StatusBadRequest)
}
}
func (h *DeviceHandlers) HandleDeviceStream(w http.ResponseWriter, req *http.Request) {
// Extract device serial from path: /api/devices/{serial}/stream
path := strings.TrimPrefix(req.URL.Path, "/api/devices/")
parts := strings.Split(path, "/")
deviceSerial := parts[0]
if strings.Contains(req.Header.Get("via"), "gbox-device-ap") {
deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial)
}
if deviceSerial == "" {
http.Error(w, "Device serial required", http.StatusBadRequest)
return
}
log.Printf("[HandleDeviceStream] Processing stream request for device: %s", deviceSerial)
// Parse query parameters
codec := req.URL.Query().Get("codec")
if codec == "" {
codec = "h264+aac"
}
format := req.URL.Query().Get("format")
if format == "" {
format = "mp4"
}
log.Printf("[HandleDeviceStream] Parameters - codec: %s, format: %s", codec, format)
// Validate parameters - Go's url.Query().Get() automatically decodes URL encoding
if codec != "h264+opus" && codec != "h264+aac" {
http.Error(w, "Invalid codec. Only 'h264+opus' and 'h264+aac' are supported for mixed streams", http.StatusBadRequest)
return
}
if format != "webm" && format != "mp4" {
http.Error(w, "Invalid format. Only 'webm' and 'mp4' are supported for mixed streams", http.StatusBadRequest)
return
}
// Direct mixed stream implementation
log.Printf("[HandleDeviceStream] Using %s format for mixed stream", format)
switch format {
case "webm":
// WebM container streaming with H.264 video and Opus audio
h.HandleWebMStream(w, req, deviceSerial)
case "mp4":
// MP4 container streaming with H.264 video and AAC audio
h.HandleMP4Stream(w, req, deviceSerial)
default:
http.Error(w, "Invalid format. Supported: webm, mp4", http.StatusBadRequest)
}
}
func (h *DeviceHandlers) HandleDeviceControl(w http.ResponseWriter, req *http.Request) {
// Extract device serial from path: /api/devices/{serial}/control
path := strings.TrimPrefix(req.URL.Path, "/api/devices/")
parts := strings.Split(path, "/")
deviceSerial := parts[0]
if strings.Contains(req.Header.Get("via"), "gbox-device-ap") {
deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial)
}
if deviceSerial == "" {
http.Error(w, "Device serial required", http.StatusBadRequest)
return
}
if !isValidDeviceSerial(deviceSerial) {
http.Error(w, "Invalid device serial", http.StatusBadRequest)
return
}
log.Printf("[HandleDeviceControl] Processing control WebSocket request for device: %s", deviceSerial)
// Direct control WebSocket implementation
conn, err := controlUpgrader.Upgrade(w, req, nil)
if err != nil {
log.Printf("[HandleDeviceControl] Failed to upgrade control WebSocket: %v", err)
return
}
defer conn.Close()
log.Printf("[HandleDeviceControl] Control WebSocket connection established for device: %s", deviceSerial)
// Delegate to control service
controlService := control.GetControlService()
// Handle WebSocket messages
for {
var msg map[string]interface{}
if err := conn.ReadJSON(&msg); err != nil {
// Check for normal close conditions
if websocket.IsCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) {
log.Printf("[HandleDeviceControl] Control WebSocket closed normally for device: %s", deviceSerial)
} else if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("[HandleDeviceControl] Control WebSocket read error for device: %s: %v", deviceSerial, err)
}
break
}
msgType, ok := msg["type"].(string)
if !ok {
continue
}
slog.Debug("Control message received", "device", deviceSerial, "type", msgType)
switch msgType {
// WebRTC signaling messages
case "ping", "offer", "answer", "ice-candidate":
h.handleWebRTCMessage(conn, msg, msgType, deviceSerial)
// Device control messages
case "key":
// Handle key events
controlService.HandleKeyEvent(msg, deviceSerial)
case "text":
// Handle text input (clipboard event)
clipboardMsg := map[string]interface{}{
"text": msg["text"],
"paste": true,
}
controlService.HandleClipboardEvent(clipboardMsg, deviceSerial)
case "touch":
// Handle touch events
controlService.HandleTouchEvent(msg, deviceSerial)
case "scroll":
// Handle scroll events
controlService.HandleScrollEvent(msg, deviceSerial)
case "clipboard_set":
controlService.HandleClipboardEvent(msg, deviceSerial)
case "reset_video":
controlService.HandleVideoResetEvent(msg, deviceSerial)
default:
log.Printf("[HandleDeviceControl] Unknown message type for device: %s: %s", deviceSerial, msgType)
}
}
}
// HandleDeviceExec executes a shell command on the server, scoped under a device path
// Path: /api/devices/{serial}/exec
// Method: POST
// Body JSON: { "cmd": "echo hello", "timeoutSec": 60 }
// Response JSON: { stdout, stderr, exitCode, durationMs }
func (h *DeviceHandlers) HandleDeviceExec(w http.ResponseWriter, req *http.Request) {
// Extract device serial from path
path := strings.TrimPrefix(req.URL.Path, "/api/devices/")
parts := strings.Split(path, "/")
deviceSerial := parts[0]
if strings.Contains(req.Header.Get("via"), "gbox-device-ap") {
deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial)
}
if deviceSerial == "" {
http.Error(w, "Device serial required", http.StatusBadRequest)
return
}
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
Cmd string `json:"cmd"`
TimeoutSec int `json:"timeoutSec"`
WorkingDir string `json:"workingDir"`
Envs map[string]string `json:"envs"`
}
decoder := json.NewDecoder(req.Body)
if err := decoder.Decode(&payload); err != nil {
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
return
}
if payload.Cmd == "" {
http.Error(w, "Field 'cmd' is required", http.StatusBadRequest)
return
}
if payload.TimeoutSec <= 0 {
payload.TimeoutSec = 60
}
ctx, cancel := context.WithTimeout(req.Context(), time.Duration(payload.TimeoutSec)*time.Second)
defer cancel()
// Determine device type by looking up device information
devicePlatform := h.getDevicePlatform(deviceSerial)
// Set default workingDir based on platform
workingDir := payload.WorkingDir
if workingDir == "" {
if devicePlatform == "mobile" {
workingDir = "/data/local/tmp"
} else {
workingDir = "/"
}
}
var cmd *exec.Cmd
if devicePlatform == "mobile" {
// Execute command on Android device via adb shell
adbPath, err := exec.LookPath("adb")
if err != nil {
adbPath = "adb"
}
// Build command with environment variables if provided
shellCmd := payload.Cmd
if len(payload.Envs) > 0 {
envVars := ""
for k, v := range payload.Envs {
envVars += fmt.Sprintf("export %s=%s; ", k, v)
}
shellCmd = envVars + shellCmd
}
// Set working directory and execute command
fullCmd := fmt.Sprintf("cd %s && %s", workingDir, shellCmd)
cmd = exec.CommandContext(ctx, adbPath, "-s", deviceSerial, "shell", fullCmd)
} else {
// Execute command locally on desktop device
if runtime.GOOS == "windows" {
cmd = exec.CommandContext(ctx, "cmd", "/C", payload.Cmd)
} else {
cmd = exec.CommandContext(ctx, "/bin/sh", "-c", payload.Cmd)
}
// Set working directory
cmd.Dir = workingDir
// Set environment variables
if len(payload.Envs) > 0 {
cmd.Env = os.Environ()
for k, v := range payload.Envs {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
}
}
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
start := time.Now()
runErr := cmd.Run()
duration := time.Since(start)
exitCode := 0
if runErr != nil {
if exitErr, ok := runErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
exitCode = -1
}
}
RespondJSON(w, http.StatusOK, map[string]interface{}{
"device": deviceSerial,
"stdout": stdoutBuf.String(),
"stderr": stderrBuf.String(),
"exitCode": exitCode,
"durationMs": duration.Milliseconds(),
})
}
// getDevicePlatform determines the platform type (mobile or desktop) for a given device serial
// Returns "mobile" for Android devices, "desktop" for desktop devices
func (h *DeviceHandlers) getDevicePlatform(deviceSerial string) string {
// First, try to get from device keeper cache (supports serialno, deviceId, or regId lookup)
deviceInfo := h.serverService.GetDeviceInfo(deviceSerial)
if deviceInfo != nil {
if dto, ok := deviceInfo.(*DeviceDTO); ok && dto.Platform != "" {
return dto.Platform
}
}
// If not in cache, try to determine from local device list
// This uses local API (not remote API) to get device information
dtos := h.getLocalDeviceList()
for _, dto := range dtos {
// Match by serialno, TransportID, or ID
if dto.Serialno == deviceSerial || dto.TransportID == deviceSerial || dto.ID == deviceSerial {
platform := dto.Platform
if platform == "" {
// Fallback: determine from OS type
if dto.OS == "android" {
platform = "mobile"
} else {
platform = "desktop"
}
}
// Update cache with complete device info for future use
h.serverService.UpdateDeviceInfo(&dto)
return platform
}
}
// Default to desktop if we can't determine (safer for local execution)
return "desktop"
}
// getLocalDeviceList gets device list locally without making remote API calls
// This is a helper function to avoid duplicating HandleDeviceList logic
func (h *DeviceHandlers) getLocalDeviceList() []DeviceDTO {
devs, err := h.deviceManager.GetDevices()
if err != nil {
return []DeviceDTO{}
}
dtos := make([]DeviceDTO, 0, len(devs))
// Add Android devices
for _, d := range devs {
dto := DeviceDTO{
ID: "",
TransportID: d.ID,
Serialno: d.SerialNo,
Platform: "mobile", // Android devices are mobile
OS: "android", // Android devices
DeviceType: util.DetectAndroidDeviceType(d.ID, d.SerialNo),
IsRegistered: false,
RegId: d.RegId,
}
dtos = append(dtos, dto)
}
// Add desktop device (always show local machine)
var osType string
switch runtime.GOOS {
case "darwin":
osType = "macos"
case "linux", "windows":
osType = strings.ToLower(runtime.GOOS)
default:
osType = "linux"
}
desktopMgr := device.NewManager(osType)
localRegId, _ := desktopMgr.GetRegId("") // non-fatal
serialno := util.GetDesktopSerialNo(osType)
desktopDTO := DeviceDTO{
ID: "",
TransportID: "local",
Serialno: serialno,
Platform: "desktop",
OS: osType,
DeviceType: util.DetectDesktopDeviceType(osType),
IsRegistered: false,
RegId: localRegId,
IsLocal: true,
Metadata: make(map[string]interface{}),
}
dtos = append(dtos, desktopDTO)
return dtos
}
// handleWebRTCMessage handles WebRTC signaling messages
func (h *DeviceHandlers) handleWebRTCMessage(conn *websocket.Conn, msg map[string]interface{}, msgType, deviceSerial string) {
if h.webrtcHandlers == nil {
log.Printf("[HandleDeviceControl] WebRTC handlers not initialized")
return
}
log.Printf("[HandleDeviceControl] Delegating WebRTC message to handler: type=%s, device=%s", msgType, deviceSerial)
switch msgType {
case "ping":
h.webrtcHandlers.HandlePing(conn, msg)
case "offer":
h.webrtcHandlers.HandleOffer(conn, msg, deviceSerial)
case "answer":
h.webrtcHandlers.HandleAnswer(conn, msg, deviceSerial)
case "ice-candidate":
h.webrtcHandlers.HandleIceCandidate(conn, msg, deviceSerial)
default:
log.Printf("[HandleDeviceControl] Unknown WebRTC message type: %s", msgType)
}
}
// HandleWebSocket handles WebSocket connections for device communication
func (h *DeviceHandlers) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("WebSocket connection established")
for {
var msg map[string]interface{}
err := conn.ReadJSON(&msg)
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket error: %v", err)
}
break
}
// Handle different message types
msgType, ok := msg["type"].(string)
if !ok {
log.Printf("Invalid message format: missing type")
continue
}
switch msgType {
case "connect":
h.handleWebSocketConnect(conn, msg)
case "offer":
h.handleWebSocketOffer(conn, msg)
case "ice-candidate":
h.handleWebSocketICECandidate(conn, msg)
case "disconnect":
h.handleWebSocketDisconnect(conn, msg)
default:
log.Printf("Unknown message type: %s", msgType)
}
}
}
func (h *DeviceHandlers) HandleDeviceAdb(w http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/api/devices/")
parts := strings.Split(path, "/")
deviceSerial := parts[0]
if strings.Contains(req.Header.Get("via"), "gbox-device-ap") {
deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial)
}
if deviceSerial == "" {
http.Error(w, "Device serial required", http.StatusBadRequest)
return
}
if !isValidDeviceSerial(deviceSerial) {
http.Error(w, "Invalid device serial", http.StatusBadRequest)
return
}
log.Printf("[HandleDeviceAdb] Processing adb request for device: %s", deviceSerial)
decoder := json.NewDecoder(req.Body)
var reqBody struct {
Command string `json:"command"`
}
if err := decoder.Decode(&reqBody); err != nil {
http.Error(w, errors.Wrap(err, "failed to parse request body").Error(), http.StatusBadRequest)
return
}
manager := device.NewManager("android")
// ExecAdbCommand is only available on AndroidManager, not DeviceManager interface
// Cast to *AndroidManager to access ExecAdbCommand
androidMgr, ok := manager.(*device.AndroidManager)
if !ok {
http.Error(w, "ExecAdbCommand is only available for Android devices", http.StatusBadRequest)
return
}
result, err := androidMgr.ExecAdbCommand(deviceSerial, reqBody.Command)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
RespondJSON(w, http.StatusOK, result)
}
// HandleDeviceAppium proxies Appium requests to the local Appium server.
// Supports all HTTP methods and WebSocket upgrades required by Appium.
func (h *DeviceHandlers) HandleDeviceAppium(w http.ResponseWriter, req *http.Request) {
deviceSerial := pathParam(req, "serial")
originalSerial := deviceSerial
if strings.Contains(req.Header.Get("via"), "gbox-device-ap") {
if resolved := h.serverService.GetSerialByDeviceId(deviceSerial); resolved != "" {
deviceSerial = resolved
} else {
log.Printf("[HandleDeviceAppium] Unable to resolve device serial for deviceId %q", deviceSerial)
}
}
if deviceSerial == "" {
http.Error(w, "Device serial required", http.StatusBadRequest)
return
}
if deviceSerial != "" && deviceSerial != originalSerial {
if rewritten := h.rewriteAppiumRequestBody(req, deviceSerial); rewritten {
log.Printf("[HandleDeviceAppium] Rewrote Appium request body to use serial %s", deviceSerial)
}
}
appiumHost := os.Getenv("APPIUM_HOST")
if appiumHost == "" {
appiumHost = "127.0.0.1"
}
appiumPort := os.Getenv("APPIUM_PORT")
if appiumPort == "" {
appiumPort = "4723"
}
targetURL := &url.URL{
Scheme: "http",
Host: fmt.Sprintf("%s:%s", appiumHost, appiumPort),
}
restPath := pathParam(req, "path")
trimmed := strings.TrimPrefix(restPath, "/")
targetPath := "/"
if trimmed != "" {
targetPath = "/" + trimmed
}
proxy := httputil.NewSingleHostReverseProxy(targetURL)
originalDirector := proxy.Director
originalHeaders := req.Header.Clone()
originalQuery := req.URL.RawQuery
proxy.Director = func(r *http.Request) {
// Preserve original headers before applying default director logic.
r.Header = originalHeaders.Clone()
originalDirector(r)
r.URL.Path = targetPath
r.URL.RawPath = targetPath
r.URL.RawQuery = originalQuery
r.Host = targetURL.Host
r.Header.Set("X-GBOX-Device-Serial", deviceSerial)
r.Header.Set("X-GBOX-Forwarded-By", "gbox-server")
}
proxy.ErrorHandler = func(rw http.ResponseWriter, r *http.Request, err error) {
log.Printf("[HandleDeviceAppium] proxy error for device %s: %v", deviceSerial, err)
http.Error(rw, fmt.Sprintf("Failed to proxy Appium request: %v", err), http.StatusBadGateway)
}
log.Printf("[HandleDeviceAppium] Proxying %s %s to %s%s", req.Method, req.URL.Path, targetURL.Host, targetPath)
proxy.ServeHTTP(w, req)
}
func (h *DeviceHandlers) rewriteAppiumRequestBody(req *http.Request, deviceSerial string) bool {
if req.Body == nil {
return false
}
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
return false
}
defer req.Body.Close()
if len(bodyBytes) == 0 {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return false
}
newBody, updated := rewriteAppiumPayload(bodyBytes, deviceSerial)
if !updated {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return false
}
req.Body = io.NopCloser(bytes.NewReader(newBody))
req.ContentLength = int64(len(newBody))
req.Header.Set("Content-Length", strconv.Itoa(len(newBody)))
return true
}
// HandleDeviceFiles handles file operations for devices
// Routes:
// - GET /api/devices/{serial}/files - read file
// - POST /api/devices/{serial}/files - write file
// - DELETE /api/devices/{serial}/files - delete file
// - GET /api/devices/{serial}/files/list - list files
// - GET /api/devices/{serial}/files/info - get file info
// - POST /api/devices/{serial}/files/rename - rename file
// - GET /api/devices/{serial}/files/exists - check file exists
func (h *DeviceHandlers) HandleDeviceFiles(w http.ResponseWriter, req *http.Request) {
// Extract device serial from PathParam (provided by PatternRouter)
deviceSerial := pathParam(req, "serial")
if strings.Contains(req.Header.Get("via"), "gbox-device-ap") {
deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial)
}
if deviceSerial == "" {
http.Error(w, "Device serial required", http.StatusBadRequest)
return
}
// Determine device platform
devicePlatform := h.getDevicePlatform(deviceSerial)
// Get workingDir from query params, default to "/" for desktop, "/data/local/tmp" for Android
workingDir := req.URL.Query().Get("workingDir")
if workingDir == "" {
if devicePlatform == "mobile" {
workingDir = "/data/local/tmp"
} else {
workingDir = "/"
}
}
// Get action from PathParam (if present)
action := pathParam(req, "action")
switch action {
case "list":
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
h.handleDeviceFilesList(w, req, deviceSerial, devicePlatform, workingDir)
case "info":
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
h.handleDeviceFilesInfo(w, req, deviceSerial, devicePlatform, workingDir)
case "rename":
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
h.handleDeviceFilesRename(w, req, deviceSerial, devicePlatform, workingDir)
case "exists":
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
h.handleDeviceFilesExists(w, req, deviceSerial, devicePlatform, workingDir)
case "":
// Base /files endpoint
switch req.Method {
case http.MethodGet:
h.handleDeviceFilesRead(w, req, deviceSerial, devicePlatform, workingDir)
case http.MethodPost:
h.handleDeviceFilesWrite(w, req, deviceSerial, devicePlatform, workingDir)
case http.MethodDelete:
h.handleDeviceFilesDelete(w, req, deviceSerial, devicePlatform, workingDir)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
default:
http.Error(w, "Invalid file operation path", http.StatusBadRequest)
}
}
// handleDeviceFilesRead reads a file from the device
func (h *DeviceHandlers) handleDeviceFilesRead(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) {
path := req.URL.Query().Get("path")
if path == "" {
http.Error(w, "path parameter is required", http.StatusBadRequest)
return
}
// Resolve absolute path
absPath := h.resolvePath(path, workingDir)
if devicePlatform == "mobile" {
// For Android, use adb pull or cat
adbPath, err := exec.LookPath("adb")
if err != nil {
adbPath = "adb"
}
// Use adb shell cat to read file
cmd := exec.Command(adbPath, "-s", deviceSerial, "shell", "cat", absPath)
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
if err := cmd.Run(); err != nil {
http.Error(w, fmt.Sprintf("Failed to read file: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write(stdoutBuf.Bytes())
} else {
// For desktop, read file directly
content, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write(content)
}
}
// handleDeviceFilesWrite writes content to a file on the device
func (h *DeviceHandlers) handleDeviceFilesWrite(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) {
path := req.URL.Query().Get("path")
if path == "" {
http.Error(w, "path parameter is required", http.StatusBadRequest)
return
}
// Resolve absolute path
absPath := h.resolvePath(path, workingDir)
// Read body content
body, err := io.ReadAll(req.Body)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read request body: %v", err), http.StatusBadRequest)
return
}
if devicePlatform == "mobile" {
// For Android, use adb push or echo
adbPath, err := exec.LookPath("adb")
if err != nil {
adbPath = "adb"
}
// Create a temporary file locally
tmpFile, err := os.CreateTemp("", "gbox-file-*")
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create temp file: %v", err), http.StatusInternalServerError)
return
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := tmpFile.Write(body); err != nil {
tmpFile.Close()
http.Error(w, fmt.Sprintf("Failed to write temp file: %v", err), http.StatusInternalServerError)
return
}
tmpFile.Close()
// Push file to device
cmd := exec.Command(adbPath, "-s", deviceSerial, "push", tmpPath, absPath)
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Run(); err != nil {
http.Error(w, fmt.Sprintf("Failed to write file: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
} else {
// For desktop, write file directly
// Create parent directories if needed
parentDir := h.getParentDir(absPath)
if err := os.MkdirAll(parentDir, 0755); err != nil {
http.Error(w, fmt.Sprintf("Failed to create parent directories: %v", err), http.StatusInternalServerError)
return
}
if err := os.WriteFile(absPath, body, 0644); err != nil {
http.Error(w, fmt.Sprintf("Failed to write file: %v", err), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleDeviceFilesDelete deletes a file or directory from the device
func (h *DeviceHandlers) handleDeviceFilesDelete(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) {
path := req.URL.Query().Get("path")
if path == "" {
http.Error(w, "path parameter is required", http.StatusBadRequest)
return
}
// Resolve absolute path
absPath := h.resolvePath(path, workingDir)
if devicePlatform == "mobile" {
// For Android, use adb shell rm
adbPath, err := exec.LookPath("adb")
if err != nil {
adbPath = "adb"
}
cmd := exec.Command(adbPath, "-s", deviceSerial, "shell", "rm", "-rf", absPath)
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Run(); err != nil {
http.Error(w, fmt.Sprintf("Failed to delete file: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
} else {
// For desktop, delete file directly
if err := os.RemoveAll(absPath); err != nil {
if os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("Failed to delete file: %v", err), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleDeviceFilesList lists files in a directory on the device
func (h *DeviceHandlers) handleDeviceFilesList(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) {
path := req.URL.Query().Get("path")
if path == "" {
path = workingDir
}
// Resolve absolute path
absPath := h.resolvePath(path, workingDir)
depthStr := req.URL.Query().Get("depth")
depth := 1
if depthStr != "" {
if d, err := strconv.Atoi(depthStr); err == nil && d > 0 {
depth = d
}
}
if devicePlatform == "mobile" {
// For Android, use adb shell ls
adbPath, err := exec.LookPath("adb")
if err != nil {
adbPath = "adb"
}
// Use find command for recursive listing with depth
var cmd *exec.Cmd
if depth > 1 {
maxDepth := depth
cmd = exec.Command(adbPath, "-s", deviceSerial, "shell", "find", absPath, "-maxdepth", strconv.Itoa(maxDepth), "-exec", "ls", "-ld", "{}", ";")
} else {
cmd = exec.Command(adbPath, "-s", deviceSerial, "shell", "ls", "-la", absPath)
}
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
if err := cmd.Run(); err != nil {
http.Error(w, fmt.Sprintf("Failed to list files: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError)
return
}
// Parse ls output and convert to JSON format
// This is a simplified implementation - in production, you'd want more robust parsing
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n")
files := make([]map[string]interface{}, 0)
for _, line := range lines {
if line == "" {
continue
}
// Parse ls -la output (simplified)
parts := strings.Fields(line)
if len(parts) < 9 {
continue
}
fileInfo := map[string]interface{}{
"name": parts[8],
"path": absPath + "/" + parts[8],
"type": "dir",
"mode": parts[0],
"lastModified": time.Now().Format(time.RFC3339), // ls doesn't provide exact time, use current
}
if strings.HasPrefix(parts[0], "-") {
fileInfo["type"] = "file"
if size, err := strconv.ParseInt(parts[4], 10, 64); err == nil {
fileInfo["size"] = size
}
}
files = append(files, fileInfo)
}
RespondJSON(w, http.StatusOK, files)
} else {
// For desktop, use os.ReadDir
entries, err := os.ReadDir(absPath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Directory not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("Failed to list directory: %v", err), http.StatusInternalServerError)
return
}
files := make([]map[string]interface{}, 0, len(entries))
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
filePath := absPath + "/" + entry.Name()
if absPath == "/" {
filePath = "/" + entry.Name()
}
fileInfo := map[string]interface{}{
"name": entry.Name(),
"path": filePath,
"type": "file",
"mode": info.Mode().String(),
"lastModified": info.ModTime().Format(time.RFC3339),
}
if entry.IsDir() {
fileInfo["type"] = "dir"
} else {
fileInfo["size"] = info.Size()
}
files = append(files, fileInfo)
}
RespondJSON(w, http.StatusOK, files)
}
}
// handleDeviceFilesInfo gets file information
func (h *DeviceHandlers) handleDeviceFilesInfo(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) {
path := req.URL.Query().Get("path")
if path == "" {
http.Error(w, "path parameter is required", http.StatusBadRequest)
return
}
// Resolve absolute path
absPath := h.resolvePath(path, workingDir)
if devicePlatform == "mobile" {
// For Android, use adb shell stat
adbPath, err := exec.LookPath("adb")
if err != nil {
adbPath = "adb"
}
format := "%n\\|%s\\|%f\\|%Y"
cmd := exec.Command(adbPath, "-s", deviceSerial, "shell", "stat", "-c", format, absPath)
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
if err := cmd.Run(); err != nil {
http.Error(w, fmt.Sprintf("Failed to get file info: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError)
return
}
// Parse stat output
output := strings.TrimSpace(stdoutBuf.String())
parts := strings.Split(output, "|")
if len(parts) < 4 {
http.Error(w, "Failed to parse file info", http.StatusInternalServerError)
return
}
size, _ := strconv.ParseInt(parts[1], 10, 64)
mode, _ := strconv.ParseUint(parts[2], 16, 32)
mtime, _ := strconv.ParseInt(parts[3], 10, 64)
fileType := "file"
if os.FileMode(mode).IsDir() {
fileType = "dir"
}
fileInfo := map[string]interface{}{
"name": parts[0],
"path": absPath,
"type": fileType,
"size": size,
"mode": fmt.Sprintf("%o", mode),
"lastModified": time.Unix(mtime, 0).Format(time.RFC3339),
}
RespondJSON(w, http.StatusOK, fileInfo)
} else {
// For desktop, use os.Stat
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("Failed to get file info: %v", err), http.StatusInternalServerError)
return
}
fileType := "file"
if info.IsDir() {
fileType = "dir"
}
fileInfo := map[string]interface{}{
"name": info.Name(),
"path": absPath,
"type": fileType,
"mode": info.Mode().String(),
"lastModified": info.ModTime().Format(time.RFC3339),
}
if !info.IsDir() {
fileInfo["size"] = info.Size()
}
RespondJSON(w, http.StatusOK, fileInfo)
}
}
// handleDeviceFilesRename renames a file or directory
func (h *DeviceHandlers) handleDeviceFilesRename(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) {
oldPath := req.URL.Query().Get("oldPath")
newPath := req.URL.Query().Get("newPath")
if oldPath == "" || newPath == "" {
http.Error(w, "oldPath and newPath parameters are required", http.StatusBadRequest)
return
}
// Resolve absolute paths
absOldPath := h.resolvePath(oldPath, workingDir)
absNewPath := h.resolvePath(newPath, workingDir)
if devicePlatform == "mobile" {
// For Android, use adb shell mv
adbPath, err := exec.LookPath("adb")
if err != nil {
adbPath = "adb"
}
cmd := exec.Command(adbPath, "-s", deviceSerial, "shell", "mv", absOldPath, absNewPath)
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Run(); err != nil {
http.Error(w, fmt.Sprintf("Failed to rename file: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
} else {
// For desktop, use os.Rename
if err := os.Rename(absOldPath, absNewPath); err != nil {
if os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("Failed to rename file: %v", err), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
// handleDeviceFilesExists checks if a file or directory exists
func (h *DeviceHandlers) handleDeviceFilesExists(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) {
path := req.URL.Query().Get("path")
if path == "" {
http.Error(w, "path parameter is required", http.StatusBadRequest)
return
}
// Resolve absolute path
absPath := h.resolvePath(path, workingDir)
if devicePlatform == "mobile" {
// For Android, use adb shell test
adbPath, err := exec.LookPath("adb")
if err != nil {
adbPath = "adb"
}
// Test if file exists
cmd := exec.Command(adbPath, "-s", deviceSerial, "shell", "test", "-e", absPath)
exists := cmd.Run() == nil
// Determine type if exists
var fileType string
if exists {
// Check if it's a directory
cmdDir := exec.Command(adbPath, "-s", deviceSerial, "shell", "test", "-d", absPath)
if cmdDir.Run() == nil {
fileType = "dir"
} else {
fileType = "file"
}
}
if exists {
RespondJSON(w, http.StatusOK, map[string]interface{}{
"exists": true,
"type": fileType,
})
} else {
RespondJSON(w, http.StatusOK, map[string]interface{}{
"exists": false,
})
}
} else {
// For desktop, use os.Stat
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
RespondJSON(w, http.StatusOK, map[string]interface{}{
"exists": false,
})
return
}
http.Error(w, fmt.Sprintf("Failed to check file existence: %v", err), http.StatusInternalServerError)
return
}
fileType := "file"
if info.IsDir() {
fileType = "dir"
}
RespondJSON(w, http.StatusOK, map[string]interface{}{
"exists": true,
"type": fileType,
})
}
}
// Helper methods for file operations
// resolvePath resolves a path relative to workingDir
func (h *DeviceHandlers) resolvePath(path, workingDir string) string {
if strings.HasPrefix(path, "/") {
return path
}
if workingDir == "/" {
return "/" + path
}
return workingDir + "/" + path
}
// getParentDir gets the parent directory of a path
func (h *DeviceHandlers) getParentDir(path string) string {
// Remove trailing slash if present
path = strings.TrimSuffix(path, "/")
lastSlash := strings.LastIndex(path, "/")
if lastSlash == -1 {
return "/"
}
if lastSlash == 0 {
return "/"
}
return path[:lastSlash]
}
// Private helper methods
func rewriteAppiumPayload(body []byte, deviceSerial string) ([]byte, bool) {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, false
}
updated := false
if caps, ok := payload["capabilities"].(map[string]any); ok {
var processedFirstMatch []any
if alwaysMatch, ok := caps["alwaysMatch"].(map[string]any); ok {
if applyAppiumSerial(alwaysMatch, deviceSerial) {
updated = true
}
}
if fm, ok := caps["firstMatch"].([]any); ok {
processedFirstMatch = make([]any, len(fm))
copy(processedFirstMatch, fm)
for i, entry := range processedFirstMatch {
if m, ok := entry.(map[string]any); ok {
removed := removeAppiumSerialKeys(m)
processedFirstMatch[i] = m
updated = updated || removed
}
}
}
if updated {
if processedFirstMatch != nil {
caps["firstMatch"] = processedFirstMatch
}
payload["capabilities"] = caps
}
}
if desiredCaps, ok := payload["desiredCapabilities"].(map[string]any); ok {
if applyAppiumSerial(desiredCaps, deviceSerial) {
payload["desiredCapabilities"] = desiredCaps
updated = true
}
}
if !updated {
return nil, false
}
newBody, err := json.Marshal(payload)
if err != nil {
return nil, false
}
return newBody, true
}
func applyAppiumSerial(m map[string]any, serial string) bool {
if m == nil {
return false
}
updated := false
for _, key := range []string{"appium:udid", "udid"} {
if _, exists := m[key]; exists || key == "appium:udid" {
m[key] = serial
updated = true
}
}
for _, key := range []string{"appium:deviceName", "deviceName"} {
if _, exists := m[key]; exists {
m[key] = serial
updated = true
}
}
return updated
}
func removeAppiumSerialKeys(m map[string]any) bool {
if m == nil {
return false
}
removed := false
for _, key := range []string{"appium:udid", "udid"} {
if _, exists := m[key]; exists {
delete(m, key)
removed = true
}
}
return removed
}
func (h *DeviceHandlers) handleDeviceConnect(w http.ResponseWriter, r *http.Request, deviceID string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// TODO: Implement actual device connection via bridge manager
if h.serverService != nil {
err := h.serverService.CreateBridge(deviceID)
if err != nil {
RespondJSON(w, http.StatusInternalServerError, map[string]interface{}{
"success": false,
"error": fmt.Sprintf("Failed to connect to device: %v", err),
})
return
}
}
RespondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"deviceId": deviceID,
"status": "connected",
})
}
func (h *DeviceHandlers) handleDeviceDisconnect(w http.ResponseWriter, r *http.Request, deviceID string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// TODO: Implement actual device disconnection
if h.serverService != nil {
h.serverService.RemoveBridge(deviceID)
}
RespondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"deviceId": deviceID,
"status": "disconnected",
})
}
func (h *DeviceHandlers) handleWebSocketConnect(conn *websocket.Conn, msg map[string]interface{}) {
// TODO: Implement WebSocket connect handling
response := map[string]interface{}{
"type": "connect-response",
"success": true,
}
conn.WriteJSON(response)
}
func (h *DeviceHandlers) handleWebSocketOffer(conn *websocket.Conn, msg map[string]interface{}) {
// TODO: Implement WebRTC offer handling
log.Printf("Received WebRTC offer: %v", msg)
}
func (h *DeviceHandlers) handleWebSocketICECandidate(conn *websocket.Conn, msg map[string]interface{}) {
// TODO: Implement ICE candidate handling
log.Printf("Received ICE candidate: %v", msg)
}
func (h *DeviceHandlers) handleWebSocketDisconnect(conn *websocket.Conn, msg map[string]interface{}) {
// TODO: Implement WebSocket disconnect handling
log.Printf("WebSocket disconnect: %v", msg)
}
// runLinuxInitNoVncScript initializes local noVNC environment on Linux hosts.
// Best-effort: returns error but callers may choose to continue.
func runLinuxInitNoVncScript() error {
if runtime.GOOS != "linux" {
return nil
}
if len(serverScripts.InitLinuxNoVncScript) == 0 {
return fmt.Errorf("init-linux-novnc script not embedded")
}
tmpFile, err := os.CreateTemp("", "gbox-init-novnc-*.sh")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.Write(serverScripts.InitLinuxNoVncScript); err != nil {
tmpFile.Close()
_ = os.Remove(tmpPath)
return err
}
if err := tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath)
return err
}
_ = os.Chmod(tmpPath, 0700)
cmd := exec.Command("bash", tmpPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
runErr := cmd.Run()
_ = os.Remove(tmpPath)
return runErr
}
// runLinuxInitXdotoolScript installs and sets up xdotool environment (best-effort)
func runLinuxInitXdotoolScript() error {
if runtime.GOOS != "linux" {
return nil
}
if len(serverScripts.InitLinuxXdotoolScript) == 0 {
return fmt.Errorf("init-linux-xdotool script not embedded")
}
tmpFile, err := os.CreateTemp("", "gbox-init-xdotool-*.sh")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.Write(serverScripts.InitLinuxXdotoolScript); err != nil {
tmpFile.Close()
_ = os.Remove(tmpPath)
return err
}
if err := tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath)
return err
}
_ = os.Chmod(tmpPath, 0700)
cmd := exec.Command("bash", tmpPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
runErr := cmd.Run()
_ = os.Remove(tmpPath)
return runErr
}
// HandleWebMStream handles WebM streaming
func (h *DeviceHandlers) HandleWebMStream(w http.ResponseWriter, r *http.Request, deviceSerial string) {
logger := util.GetLogger()
logger.Info("Starting WebM mixed stream", "device", deviceSerial)
// Set headers for WebM streaming
w.Header().Set("Content-Type", "video/webm; codecs=avc1.42E01E,opus")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Transfer-Encoding", "chunked")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Create WebM stream writer
writer := stream.NewWebMMuxer(w)
defer writer.Close()
// Start streaming with the writer
if err := h.startStream(deviceSerial, writer, "webm"); err != nil {
logger.Error("Failed to start WebM stream", "device", deviceSerial, "error", err)
http.Error(w, fmt.Sprintf("Failed to start stream: %v", err), http.StatusInternalServerError)
return
}
}
// HandleMP4Stream handles MP4 container streaming
func (h *DeviceHandlers) HandleMP4Stream(w http.ResponseWriter, r *http.Request, deviceSerial string) {
logger := util.GetLogger()
logger.Info("Starting fMP4 stream", "device", deviceSerial)
// Set headers for fMP4 streaming
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Transfer-Encoding", "chunked")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Create MP4 stream writer
writer := stream.NewFMP4Muxer(w, logger)
defer writer.Close()
// Start streaming with the writer
if err := h.startStream(deviceSerial, writer, "mp4"); err != nil {
logger.Error("Failed to start MP4 stream", "device", deviceSerial, "error", err)
http.Error(w, fmt.Sprintf("Failed to start stream: %v", err), http.StatusInternalServerError)
return
}
}
// startStream starts a mixed audio/video stream with the given writer using StreamManager
func (h *DeviceHandlers) startStream(deviceSerial string, writer stream.Muxer, mode string) error {
logger := util.GetLogger()
// Create stream manager for protocol abstraction
streamManager := stream.NewStreamManager(logger)
// Configure stream
config := stream.StreamConfig{
DeviceSerial: deviceSerial,
Mode: mode,
VideoWidth: 1920, // Will be updated from source
VideoHeight: 1080, // Will be updated from source
}
// Start stream using stream manager
result, err := streamManager.StartStream(context.Background(), config)
if err != nil {
return fmt.Errorf("failed to start stream: %w", err)
}
defer result.Cleanup()
// Get actual device dimensions
_, videoWidth, videoHeight := result.Source.GetConnectionInfo()
logger.Info("Device video dimensions", "width", videoWidth, "height", videoHeight)
// Initialize the stream writer with device dimensions
if err := writer.Initialize(videoWidth, videoHeight, result.CodecParams); err != nil {
return fmt.Errorf("failed to initialize stream writer: %w", err)
}
// Convert channels to muxer format
videoSampleCh, audioSampleCh := streamManager.ConvertToMuxerSamples(result.VideoCh, result.AudioCh)
// Start streaming
logger.Info("Mixed stream started", "device", deviceSerial, "mode", mode)
// Use the writer's streaming method
return writer.Stream(videoSampleCh, audioSampleCh)
}