From cf2325e76045df0a72558a41adcc1e433c6bbe08 Mon Sep 17 00:00:00 2001 From: Vangie Du Date: Wed, 10 Sep 2025 18:28:39 +0800 Subject: [PATCH 01/34] :star: feat: implement new scrcpy for web - Add new implementations of scrcpy to support web usage. --- package.json | 5 + packages/cli/.gitignore | 4 +- packages/cli/Makefile | 20 +- packages/cli/cmd/adb_expose_new.go | 181 + packages/cli/cmd/device_connect.go | 111 +- packages/cli/cmd/device_connect_server.go | 48 + packages/cli/cmd/root.go | 3 + packages/cli/cmd/server.go | 282 ++ packages/cli/go.mod | 20 + packages/cli/go.sum | 40 + packages/cli/internal/daemon/manager.go | 252 + packages/cli/internal/daemon/manager_unix.go | 34 + .../cli/internal/daemon/manager_windows.go | 49 + .../internal/device_connect/api/handlers.go | 172 + .../cli/internal/device_connect/api/server.go | 177 + .../internal/device_connect/api/websocket.go | 323 ++ .../internal/device_connect/daemon_native.go | 182 + .../device_connect/device/connection.go | 262 + .../internal/device_connect/device/manager.go | 148 + .../internal/device_connect/device/media.go | 158 + .../device_connect/protocol/control.go | 239 + .../device_connect/protocol/scrcpy.go | 299 ++ .../cli/internal/device_connect/server.go | 57 + .../internal/device_connect/stream/audio.go | 99 + .../internal/device_connect/stream/control.go | 502 ++ .../internal/device_connect/stream/video.go | 173 + .../internal/device_connect/webrtc/bridge.go | 830 ++++ .../internal/device_connect/webrtc/debug.go | 25 + .../internal/device_connect/webrtc/manager.go | 86 + .../device_connect/webrtc/peer_connection.go | 136 + packages/cli/internal/server/adb_expose.go | 327 ++ .../cli/internal/server/device_connect.go | 497 ++ packages/cli/internal/server/server.go | 415 ++ .../cli/internal/server/static/index.html | 118 + packages/cli/internal/server/ui_handlers.go | 399 ++ .../cli/scripts/download-scrcpy-server.sh | 35 + packages/live-view/.gitignore | 40 + packages/live-view/Makefile | 39 + packages/live-view/README.md | 92 + packages/live-view/index.html | 30 + packages/live-view/package.json | 65 + packages/live-view/pnpm-lock.yaml | 4267 +++++++++++++++++ packages/live-view/rollup.config.js | 46 + packages/live-view/scripts/build.sh | 33 + packages/live-view/scripts/check-rebuild.sh | 43 + .../src/components/AndroidLiveView.module.css | 99 + .../src/components/AndroidLiveView.tsx | 249 + .../src/components/ControlButtons.module.css | 72 + .../src/components/ControlButtons.tsx | 114 + .../src/components/DeviceList.module.css | 108 + .../live-view/src/components/DeviceList.tsx | 105 + .../live-view/src/components/LiveView.tsx | 12 + packages/live-view/src/hooks/index.ts | 7 + .../live-view/src/hooks/useClickHandler.ts | 80 + .../src/hooks/useClipboardHandler.ts | 100 + .../live-view/src/hooks/useControlHandler.ts | 40 + .../live-view/src/hooks/useDeviceManager.ts | 61 + .../live-view/src/hooks/useKeyboardHandler.ts | 279 ++ .../live-view/src/hooks/useMouseHandler.ts | 60 + .../live-view/src/hooks/useWheelHandler.ts | 81 + packages/live-view/src/index.ts | 4 + packages/live-view/src/lib/webrtc-client.ts | 1070 +++++ packages/live-view/src/main.css | 22 + packages/live-view/src/main.tsx | 22 + packages/live-view/src/types.ts | 64 + packages/live-view/src/types/css-modules.d.ts | 4 + packages/live-view/tsconfig.json | 31 + packages/live-view/vite.config.ts | 32 + pnpm-lock.yaml | 22 + 69 files changed, 14057 insertions(+), 44 deletions(-) create mode 100644 package.json create mode 100644 packages/cli/cmd/adb_expose_new.go create mode 100644 packages/cli/cmd/device_connect_server.go create mode 100644 packages/cli/cmd/server.go create mode 100644 packages/cli/internal/daemon/manager.go create mode 100644 packages/cli/internal/daemon/manager_unix.go create mode 100644 packages/cli/internal/daemon/manager_windows.go create mode 100644 packages/cli/internal/device_connect/api/handlers.go create mode 100644 packages/cli/internal/device_connect/api/server.go create mode 100644 packages/cli/internal/device_connect/api/websocket.go create mode 100644 packages/cli/internal/device_connect/daemon_native.go create mode 100644 packages/cli/internal/device_connect/device/connection.go create mode 100644 packages/cli/internal/device_connect/device/manager.go create mode 100644 packages/cli/internal/device_connect/device/media.go create mode 100644 packages/cli/internal/device_connect/protocol/control.go create mode 100644 packages/cli/internal/device_connect/protocol/scrcpy.go create mode 100644 packages/cli/internal/device_connect/server.go create mode 100644 packages/cli/internal/device_connect/stream/audio.go create mode 100644 packages/cli/internal/device_connect/stream/control.go create mode 100644 packages/cli/internal/device_connect/stream/video.go create mode 100644 packages/cli/internal/device_connect/webrtc/bridge.go create mode 100644 packages/cli/internal/device_connect/webrtc/debug.go create mode 100644 packages/cli/internal/device_connect/webrtc/manager.go create mode 100644 packages/cli/internal/device_connect/webrtc/peer_connection.go create mode 100644 packages/cli/internal/server/adb_expose.go create mode 100644 packages/cli/internal/server/device_connect.go create mode 100644 packages/cli/internal/server/server.go create mode 100644 packages/cli/internal/server/static/index.html create mode 100644 packages/cli/internal/server/ui_handlers.go create mode 100755 packages/cli/scripts/download-scrcpy-server.sh create mode 100644 packages/live-view/.gitignore create mode 100644 packages/live-view/Makefile create mode 100644 packages/live-view/README.md create mode 100644 packages/live-view/index.html create mode 100644 packages/live-view/package.json create mode 100644 packages/live-view/pnpm-lock.yaml create mode 100644 packages/live-view/rollup.config.js create mode 100755 packages/live-view/scripts/build.sh create mode 100755 packages/live-view/scripts/check-rebuild.sh create mode 100644 packages/live-view/src/components/AndroidLiveView.module.css create mode 100644 packages/live-view/src/components/AndroidLiveView.tsx create mode 100644 packages/live-view/src/components/ControlButtons.module.css create mode 100644 packages/live-view/src/components/ControlButtons.tsx create mode 100644 packages/live-view/src/components/DeviceList.module.css create mode 100644 packages/live-view/src/components/DeviceList.tsx create mode 100644 packages/live-view/src/components/LiveView.tsx create mode 100644 packages/live-view/src/hooks/index.ts create mode 100644 packages/live-view/src/hooks/useClickHandler.ts create mode 100644 packages/live-view/src/hooks/useClipboardHandler.ts create mode 100644 packages/live-view/src/hooks/useControlHandler.ts create mode 100644 packages/live-view/src/hooks/useDeviceManager.ts create mode 100644 packages/live-view/src/hooks/useKeyboardHandler.ts create mode 100644 packages/live-view/src/hooks/useMouseHandler.ts create mode 100644 packages/live-view/src/hooks/useWheelHandler.ts create mode 100644 packages/live-view/src/index.ts create mode 100644 packages/live-view/src/lib/webrtc-client.ts create mode 100644 packages/live-view/src/main.css create mode 100644 packages/live-view/src/main.tsx create mode 100644 packages/live-view/src/types.ts create mode 100644 packages/live-view/src/types/css-modules.d.ts create mode 100644 packages/live-view/tsconfig.json create mode 100644 packages/live-view/vite.config.ts create mode 100644 pnpm-lock.yaml diff --git a/package.json b/package.json new file mode 100644 index 00000000..26a3c1d1 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "tslib": "^2.8.1" + } +} diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index 86b6272d..882debad 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -5,4 +5,6 @@ gbox-darwin-* gbox-linux-* gbox-windows-* gbox -gbox-test \ No newline at end of file +gbox-test + +assets/scrcpy-server*.jar \ No newline at end of file diff --git a/packages/cli/Makefile b/packages/cli/Makefile index 2c4ec53e..575c968c 100644 --- a/packages/cli/Makefile +++ b/packages/cli/Makefile @@ -56,8 +56,24 @@ clean: ## Clean the build directory @rm -f $(BINARY_NAME)* @echo "Cleaning completed" +# Build dependencies (live-view and scrcpy-server) +build-deps: build-live-view download-scrcpy-server ## Build all dependencies + +# Build live-view static files +build-live-view: ## Build live-view static files + @$(MAKE) -C ../live-view build + +# Download scrcpy-server.jar +download-scrcpy-server: ## Download scrcpy-server.jar + @if [ ! -f "assets/scrcpy-server.jar" ]; then \ + echo "Downloading scrcpy-server.jar..."; \ + ./scripts/download-scrcpy-server.sh; \ + else \ + echo "scrcpy-server.jar already exists"; \ + fi + # Build binary for a single platform -binary: ## Build binary for the current platform (GOOS/GOARCH) +binary: build-deps ## Build binary for the current platform (GOOS/GOARCH) @echo "Building $(BINARY_NAME) binary ($(GOOS)/$(GOARCH))..." CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build $(LDFLAGS) -o $(BINARY_NAME) $(MAIN_FILE) @echo "Binary built: $(BINARY_NAME)" @@ -74,7 +90,7 @@ test: ## Run tests go test ./... -v # Build binaries for all supported platforms -binary-all: ## Build binaries for all supported platforms +binary-all: build-deps ## Build binaries for all supported platforms @echo "Building binaries for all supported platforms..." @for platform in $(PLATFORMS); do \ os=$$(echo $$platform | cut -d- -f1); \ diff --git a/packages/cli/cmd/adb_expose_new.go b/packages/cli/cmd/adb_expose_new.go new file mode 100644 index 00000000..12b1f535 --- /dev/null +++ b/packages/cli/cmd/adb_expose_new.go @@ -0,0 +1,181 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/babelcloud/gbox/packages/cli/internal/daemon" + "github.com/spf13/cobra" +) + +// NewAdbExposeCommand creates the adb-expose command that uses the unified server +func NewAdbExposeCommandNew() *cobra.Command { + var ( + localPort int + remotePort int + device string + protocol string + list bool + remove bool + ) + + cmd := &cobra.Command{ + Use: "adb-expose", + Short: "Expose ADB ports (similar to adb forward)", + Long: `Expose ADB ports from Android device to local machine. +This is similar to 'adb forward' but managed by the gbox server. + +The gbox server will be automatically started if not running.`, + RunE: func(cmd *cobra.Command, args []string) error { + // List all forwards + if list { + return listForwards() + } + + // Remove forward + if remove { + if localPort == 0 { + return fmt.Errorf("--local-port required for --remove") + } + return removeForward(device, localPort, remotePort) + } + + // Add forward + if localPort == 0 || remotePort == 0 { + return fmt.Errorf("both --local-port and --remote-port are required") + } + + return addForward(device, localPort, remotePort, protocol) + }, + Example: ` # Forward port 8080 from device to local port 8080 + gbox adb-expose -l 8080 -r 8080 + + # Forward with specific device + gbox adb-expose -d emulator-5554 -l 8080 -r 8080 + + # List all forwards + gbox adb-expose --list + + # Remove a forward + gbox adb-expose --remove -l 8080`, + } + + flags := cmd.Flags() + flags.IntVarP(&localPort, "local-port", "l", 0, "Local port to forward to") + flags.IntVarP(&remotePort, "remote-port", "r", 0, "Remote port on device") + flags.StringVarP(&device, "device", "d", "", "Target device serial") + flags.StringVarP(&protocol, "protocol", "p", "tcp", "Protocol (tcp or unix)") + flags.BoolVar(&list, "list", false, "List all port forwards") + flags.BoolVar(&remove, "remove", false, "Remove a port forward") + + return cmd +} + +func addForward(device string, localPort, remotePort int, protocol string) error { + req := map[string]interface{}{ + "device_serial": device, + "local_port": localPort, + "remote_port": remotePort, + "protocol": protocol, + } + + var resp map[string]interface{} + if err := daemon.DefaultManager.CallAPI("POST", "/api/adb-expose/start", req, &resp); err != nil { + return fmt.Errorf("failed to add forward: %v", err) + } + + if success, ok := resp["success"].(bool); ok && success { + fmt.Printf("Port forward added: %d -> %d\n", localPort, remotePort) + } else { + return fmt.Errorf("failed to add forward: %v", resp["error"]) + } + + return nil +} + +func removeForward(device string, localPort, remotePort int) error { + req := map[string]interface{}{ + "device_serial": device, + "local_port": localPort, + "remote_port": remotePort, + } + + var resp map[string]interface{} + if err := daemon.DefaultManager.CallAPI("POST", "/api/adb-expose/stop", req, &resp); err != nil { + return fmt.Errorf("failed to remove forward: %v", err) + } + + if success, ok := resp["success"].(bool); ok && success { + fmt.Println("Port forward removed") + } else { + return fmt.Errorf("failed to remove forward: %v", resp["error"]) + } + + return nil +} + +func listForwards() error { + var resp map[string]interface{} + if err := daemon.DefaultManager.CallAPI("GET", "/api/adb-expose/list", nil, &resp); err != nil { + return fmt.Errorf("failed to list forwards: %v", err) + } + + // Display all forwards + if forwards, ok := resp["forwards"].([]interface{}); ok && len(forwards) > 0 { + fmt.Println("Active port forwards:") + for _, f := range forwards { + if forward, ok := f.(map[string]interface{}); ok { + device := forward["device_serial"].(string) + local := forward["local"].(string) + remote := forward["remote"].(string) + + // Parse ports if available + localPort := "" + remotePort := "" + + if lp, ok := forward["local_port"].(float64); ok { + localPort = strconv.Itoa(int(lp)) + } else { + // Extract from string like "tcp:8080" + parts := strings.Split(local, ":") + if len(parts) > 1 { + localPort = parts[1] + } + } + + if rp, ok := forward["remote_port"].(float64); ok { + remotePort = strconv.Itoa(int(rp)) + } else { + // Extract from string like "tcp:8080" + parts := strings.Split(remote, ":") + if len(parts) > 1 { + remotePort = parts[1] + } + } + + fmt.Printf(" %s: %s -> %s\n", device, localPort, remotePort) + } + } + } else { + fmt.Println("No active port forwards") + } + + // Display managed forwards + if managed, ok := resp["managed"].([]interface{}); ok && len(managed) > 0 { + fmt.Println("\nManaged by gbox server:") + for _, m := range managed { + if forward, ok := m.(map[string]interface{}); ok { + device := forward["device_serial"].(string) + localPort := int(forward["local_port"].(float64)) + remotePort := int(forward["remote_port"].(float64)) + protocol := forward["protocol"].(string) + + fmt.Printf(" %s: %s:%d -> %s:%d\n", + device, "tcp", localPort, protocol, remotePort) + } + } + } + + return nil +} \ No newline at end of file diff --git a/packages/cli/cmd/device_connect.go b/packages/cli/cmd/device_connect.go index fec7d944..2f5a6bc4 100644 --- a/packages/cli/cmd/device_connect.go +++ b/packages/cli/cmd/device_connect.go @@ -9,6 +9,7 @@ import ( "syscall" "github.com/babelcloud/gbox/packages/cli/config" + "github.com/babelcloud/gbox/packages/cli/internal/daemon" "github.com/babelcloud/gbox/packages/cli/internal/device_connect" "github.com/babelcloud/gbox/packages/cli/internal/profile" "github.com/fatih/color" @@ -91,6 +92,7 @@ func printFrpcInstallationHint() { type DeviceConnectOptions struct { DeviceID string Background bool + UseNative bool // Use native Go implementation instead of external binary } // Global client instance @@ -141,11 +143,13 @@ to remote cloud services for remote access and debugging.`, flags := cmd.Flags() flags.StringVarP(&opts.DeviceID, "device", "d", "", "Specify the Android device ID to connect to") flags.BoolVarP(&opts.Background, "background", "b", false, "Run connection in background") + flags.BoolVar(&opts.UseNative, "native", false, "Use native Go implementation (experimental)") cmd.AddCommand( NewDeviceConnectListCommand(), NewDeviceConnectUnregisterCommand(), NewDeviceConnectKillServerCommand(), + NewDeviceConnectServerCommand(), ) return cmd @@ -157,15 +161,14 @@ func ExecuteDeviceConnect(cmd *cobra.Command, opts *DeviceConnectOptions, args [ return fmt.Errorf("ADB is not installed or not in your PATH. Please install ADB and try again.") } - if !checkFrpcInstalled() { - printFrpcInstallationHint() - return fmt.Errorf("frpc is not installed or not in your PATH. Please install frpc and try again.") - } - - // Ensure device proxy service is running - if err := device_connect.EnsureDeviceProxyRunning(isServiceRunning); err != nil { - return fmt.Errorf("failed to start device proxy service: %v", err) - } + // Always use the unified server (like adb start-server) + // The server will be auto-started if not running + + // Note: Legacy mode with external binaries is being phased out + // All functionality now goes through the unified gbox server + + // The actual device connection will happen via HTTP API calls + // to the server, which will be started automatically by the daemon manager if opts.DeviceID == "" { return runInteractiveDeviceSelection(opts) @@ -222,11 +225,21 @@ func isServiceRunning() (bool, error) { } func runInteractiveDeviceSelection(opts *DeviceConnectOptions) error { - client := getDeviceClient() - devices, err := client.GetDevices() - if err != nil { + // Use daemon manager to call API + var response struct { + Success bool `json:"success"` + Devices []map[string]interface{} `json:"devices"` + } + + if err := daemon.DefaultManager.CallAPI("GET", "/api/devices", nil, &response); err != nil { return fmt.Errorf("failed to get available devices: %v", err) } + + if !response.Success { + return fmt.Errorf("failed to get devices from server") + } + + devices := response.Devices if len(devices) == 0 { fmt.Println("No Android devices found.") fmt.Println() @@ -242,17 +255,34 @@ func runInteractiveDeviceSelection(opts *DeviceConnectOptions) error { for i, device := range devices { status := "Not Registered" - statusColor := color.New(color.Faint) // 使用淡色(灰色) - if device.IsRegistrable { // Assuming IsRegistrable should be IsRegistered + statusColor := color.New(color.Faint) + + // Extract device info from map + serialNo := device["ro.serialno"].(string) + connectionType := device["connectionType"].(string) + isRegistered, _ := device["isRegistrable"].(bool) + + if isRegistered { status = "Registered" statusColor = color.New(color.FgGreen) } + + model := "Unknown" + if m, ok := device["ro.product.model"].(string); ok { + model = m + } + + manufacturer := "" + if mfr, ok := device["ro.product.manufacturer"].(string); ok { + manufacturer = mfr + } + fmt.Printf("%d. %s (%s, %s) - %s [%s]\n", i+1, - color.New(color.FgCyan).Sprint(device.SerialNo+"-"+device.ConnectionType), - device.ProductModel, - device.ConnectionType, - device.ProductManufacturer, + color.New(color.FgCyan).Sprint(serialNo+"-"+connectionType), + model, + connectionType, + manufacturer, statusColor.Sprint(status)) } fmt.Println() @@ -264,32 +294,36 @@ func runInteractiveDeviceSelection(opts *DeviceConnectOptions) error { } selectedDevice := devices[choice-1] - return connectToDevice(selectedDevice.Id, opts) + deviceID := selectedDevice["id"].(string) + return connectToDevice(deviceID, opts) } func connectToDevice(deviceID string, opts *DeviceConnectOptions) error { - client := getDeviceClient() - - device, err := client.GetDeviceInfo(deviceID) - if err != nil { - return fmt.Errorf("failed to get device info: %v", err) - } - - fmt.Printf("Establishing remote connection for %s (%s, %s)...\n", - device.ProductModel, device.ConnectionType, device.ProductManufacturer) - - // Register the device - if err := client.RegisterDevice(deviceID); err != nil { + // Register device via daemon API + req := map[string]string{"deviceId": deviceID} + var resp map[string]interface{} + + if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/register", req, &resp); err != nil { return fmt.Errorf("failed to register device: %v", err) } + + if success, ok := resp["success"].(bool); !ok || !success { + return fmt.Errorf("failed to register device: %v", resp["error"]) + } + + fmt.Printf("Establishing remote connection for device %s...\n", deviceID) fmt.Printf("Connection established successfully!\n") + + // Display local Web UI URL + fmt.Printf("\n📱 View and control your device at: %s\n", color.CyanString("http://localhost:29888")) + fmt.Printf(" This is the local live-view interface for device control\n") // Get and display devices URL for the current profile pm := profile.NewProfileManager() if err := pm.Load(); err == nil { if devicesURL, err := pm.GetDevicesURL(); err == nil { - fmt.Printf("You can view your devices at: %s\n", color.CyanString(devicesURL)) + fmt.Printf("\n☁️ Remote access available at: %s\n", color.CyanString(devicesURL)) } } @@ -305,19 +339,14 @@ func connectToDevice(deviceID string, opts *DeviceConnectOptions) error { signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan - fmt.Printf("Disconnecting %s (%s, %s)...\n", - device.ProductModel, device.ConnectionType, device.ProductManufacturer) + fmt.Printf("Disconnecting device %s...\n", deviceID) - // First unregister the device - if err := client.UnregisterDevice(deviceID); err != nil { + // Unregister the device via daemon API + req = map[string]string{"deviceId": deviceID} + if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/unregister", req, nil); err != nil { fmt.Printf("Warning: failed to unregister device: %v\n", err) } - // Then stop the device proxy service using existing kill-server logic - if err := executeKillServer(); err != nil { - fmt.Printf("Warning: failed to stop device proxy service: %v\n", err) - } - return nil } diff --git a/packages/cli/cmd/device_connect_server.go b/packages/cli/cmd/device_connect_server.go new file mode 100644 index 00000000..c76b11d1 --- /dev/null +++ b/packages/cli/cmd/device_connect_server.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect" + "github.com/spf13/cobra" +) + +// NewDeviceConnectServerCommand creates the start-server subcommand +func NewDeviceConnectServerCommand() *cobra.Command { + var port int + + cmd := &cobra.Command{ + Use: "start-server", + Short: "Start the native device proxy server", + Hidden: true, // Hidden command for internal use + RunE: func(cmd *cobra.Command, args []string) error { + // This command is called by the daemon process + log.Printf("Starting native device proxy server on port %d", port) + + server := device_connect.NewServer(port) + if err := server.Start(); err != nil { + return fmt.Errorf("failed to start server: %v", err) + } + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Println("Shutting down server...") + if err := server.Stop(); err != nil { + log.Printf("Error stopping server: %v", err) + } + + return nil + }, + } + + cmd.Flags().IntVar(&port, "port", device_connect.DefaultPort, "Server port") + + return cmd +} \ No newline at end of file diff --git a/packages/cli/cmd/root.go b/packages/cli/cmd/root.go index df2953ed..4531d590 100644 --- a/packages/cli/cmd/root.go +++ b/packages/cli/cmd/root.go @@ -71,6 +71,9 @@ func init() { rootCmd.AddCommand(NewDeviceConnectCommand()) rootCmd.AddCommand(NewPruneCommand()) + // Add unified server command with subcommands + rootCmd.AddCommand(NewServerCmd()) + // Enable custom help output ordering setupHelpCommand(rootCmd) } diff --git a/packages/cli/cmd/server.go b/packages/cli/cmd/server.go new file mode 100644 index 00000000..a78bb00b --- /dev/null +++ b/packages/cli/cmd/server.go @@ -0,0 +1,282 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/daemon" + "github.com/babelcloud/gbox/packages/cli/internal/server" + "github.com/spf13/cobra" +) + +// NewServerCmd creates the server command with subcommands +func NewServerCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "server", + Short: "Manage the gbox server", + Long: `Manage the gbox server daemon for device operations.`, + } + + cmd.AddCommand(newServerStartCmd()) + cmd.AddCommand(newServerStopCmd()) + cmd.AddCommand(newServerStatusCmd()) + cmd.AddCommand(newServerRestartCmd()) + + return cmd +} + +// newServerStartCmd creates the 'server start' subcommand +func newServerStartCmd() *cobra.Command { + var ( + port int + foreground bool + ) + + cmd := &cobra.Command{ + Use: "start", + Short: "Start the server", + Long: `Start the gbox server if it's not already running.`, + RunE: func(cmd *cobra.Command, args []string) error { + if foreground { + // Run in foreground mode + return runServerInForeground(port) + } + // Run in background mode (default) + return startServerInBackground(port) + }, + Example: ` # Start server in background + gbox server start + + # Start server in foreground (see logs) + gbox server start --foreground + gbox server start -f + + # Start server on specific port + gbox server start -p 8080`, + } + + flags := cmd.Flags() + flags.IntVarP(&port, "port", "p", 29888, "Server port") + flags.BoolVarP(&foreground, "foreground", "f", false, "Run server in foreground (show logs)") + + return cmd +} + +// newServerStopCmd creates the 'server stop' subcommand +func newServerStopCmd() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop the server", + Long: `Stop the gbox server if it's running.`, + RunE: func(cmd *cobra.Command, args []string) error { + dm := daemon.NewManager() + + if force { + // Force stop all processes + dm.CleanupOldServers() + fmt.Println("Force stopped all server processes") + return nil + } + + // Normal stop + if !dm.IsServerRunning() { + fmt.Println("Server is not running") + return nil + } + + fmt.Println("Stopping gbox server...") + if err := dm.StopServer(); err != nil { + // Try force cleanup if normal stop fails + dm.CleanupOldServers() + fmt.Println("Server stopped (forced)") + return nil + } + + fmt.Println("Server stopped successfully") + return nil + }, + Example: ` # Stop the server + gbox server stop + + # Force stop all server processes + gbox server stop --force + gbox server stop -f`, + } + + flags := cmd.Flags() + flags.BoolVarP(&force, "force", "f", false, "Force stop all server processes") + + return cmd +} + +// newServerStatusCmd creates the 'server status' subcommand +func newServerStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Check server status", + Long: `Check if the gbox server is running and display its status.`, + RunE: func(cmd *cobra.Command, args []string) error { + dm := daemon.NewManager() + + if dm.IsServerRunning() { + fmt.Println("✅ Server is running") + fmt.Printf(" Web UI: http://localhost:29888\n") + fmt.Printf(" API endpoint: http://localhost:29888/api/status\n") + + // Try to get more info from API + client := &http.Client{Timeout: 2 * time.Second} + if resp, err := client.Get("http://localhost:29888/api/status"); err == nil { + defer resp.Body.Close() + var status map[string]interface{} + if json.NewDecoder(resp.Body).Decode(&status) == nil { + if services, ok := status["services"].(map[string]interface{}); ok { + fmt.Println(" Services:") + for name, active := range services { + if active.(bool) { + fmt.Printf(" - %s: active\n", name) + } + } + } + } + } + } else { + fmt.Println("❌ Server is not running") + fmt.Println(" Use 'gbox server start' to start the server") + } + + return nil + }, + } + + return cmd +} + +// newServerRestartCmd creates the 'server restart' subcommand +func newServerRestartCmd() *cobra.Command { + var ( + port int + foreground bool + ) + + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart the server", + Long: `Stop and then start the gbox server.`, + RunE: func(cmd *cobra.Command, args []string) error { + dm := daemon.NewManager() + + // Stop server if it's running + if dm.IsServerRunning() { + fmt.Println("Stopping gbox server...") + if err := dm.StopServer(); err != nil { + // Try force cleanup if normal stop fails + dm.CleanupOldServers() + fmt.Println("Server stopped (forced)") + } else { + fmt.Println("Server stopped successfully") + } + + // Wait a moment for cleanup + time.Sleep(500 * time.Millisecond) + } + + // Start server + if foreground { + // Run in foreground mode + fmt.Println("Restarting server in foreground mode...") + return runServerInForeground(port) + } + + // Start in background mode + fmt.Printf("Starting gbox server on port %d...\n", port) + if err := dm.StartServer(); err != nil { + return fmt.Errorf("failed to start server: %v", err) + } + + fmt.Println("Server restarted successfully") + fmt.Printf("Web UI available at: http://localhost:%d\n", port) + return nil + }, + Example: ` # Restart the server + gbox server restart + + # Restart in foreground mode + gbox server restart --foreground + gbox server restart -f + + # Restart on specific port + gbox server restart -p 8080`, + } + + flags := cmd.Flags() + flags.IntVarP(&port, "port", "p", 29888, "Server port") + flags.BoolVarP(&foreground, "foreground", "f", false, "Run server in foreground after restart (show logs)") + + return cmd +} + +// Helper functions + +func startServerInBackground(port int) error { + dm := daemon.NewManager() + + // Check if already running + if dm.IsServerRunning() { + fmt.Println("Server is already running") + fmt.Printf("Web UI available at: http://localhost:%d\n", port) + return nil + } + + fmt.Printf("Starting gbox server on port %d...\n", port) + if err := dm.StartServer(); err != nil { + return fmt.Errorf("failed to start server: %v", err) + } + + fmt.Println("Server started successfully") + fmt.Printf("Web UI available at: http://localhost:%d\n", port) + return nil +} + +func runServerInForeground(port int) error { + // Check if another instance is running + dm := daemon.NewManager() + if dm.IsServerRunning() { + fmt.Println("Warning: Another server instance is already running") + fmt.Println("Stop it first with 'gbox server stop' or use a different port") + return fmt.Errorf("server already running") + } + + log.Printf("Starting gbox server on port %d (foreground mode)", port) + + srv := server.NewGBoxServer(port) + if err := srv.Start(); err != nil { + return fmt.Errorf("failed to start server: %v", err) + } + + fmt.Printf("Server running on http://localhost:%d\n", port) + fmt.Println("Services available:") + fmt.Printf(" - Device Connect (WebRTC): http://localhost:%d/\n", port) + fmt.Printf(" - ADB Expose API: http://localhost:%d/api/adb-expose/status\n", port) + fmt.Printf(" - Server Status: http://localhost:%d/api/status\n", port) + fmt.Println("\nPress Ctrl+C to stop...") + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Println("Shutting down server...") + if err := srv.Stop(); err != nil { + log.Printf("Error stopping server: %v", err) + } + + return nil +} \ No newline at end of file diff --git a/packages/cli/go.mod b/packages/cli/go.mod index d1ecdc4b..a9480933 100644 --- a/packages/cli/go.mod +++ b/packages/cli/go.mod @@ -6,6 +6,7 @@ require ( github.com/adrg/xdg v0.5.3 github.com/babelcloud/gbox-sdk-go v0.1.0-alpha.3 github.com/gorilla/websocket v1.5.3 + github.com/pion/webrtc/v4 v4.1.4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 @@ -30,13 +31,32 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pion/datachannel v1.5.10 // indirect + github.com/pion/dtls/v3 v3.0.7 // indirect + github.com/pion/ice/v4 v4.0.10 // indirect + github.com/pion/interceptor v0.1.40 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.15 // indirect + github.com/pion/rtp v1.8.21 // indirect + github.com/pion/sctp v1.8.39 // indirect + github.com/pion/sdp/v3 v3.0.15 // indirect + github.com/pion/srtp/v3 v3.0.7 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v4 v4.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect golang.org/x/sys v0.32.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/packages/cli/go.sum b/packages/cli/go.sum index 6dbce23d..093b8e54 100644 --- a/packages/cli/go.sum +++ b/packages/cli/go.sum @@ -17,6 +17,8 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -35,6 +37,38 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= +github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= +github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= +github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= +github.com/pion/rtp v1.8.21 h1:3yrOwmZFyUpcIosNcWRpQaU+UXIJ6yxLuJ8Bx0mw37Y= +github.com/pion/rtp v1.8.21/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= +github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sdp/v3 v3.0.15 h1:F0I1zds+K/+37ZrzdADmx2Q44OFDOPRLhPnNTaUX9hk= +github.com/pion/sdp/v3 v3.0.15/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/srtp/v3 v3.0.7 h1:QUElw0A/FUg3MP8/KNMZB3i0m8F9XeMnTum86F7S4bs= +github.com/pion/srtp/v3 v3.0.7/go.mod h1:qvnHeqbhT7kDdB+OGB05KA/P067G3mm7XBfLaLiaNF0= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= +github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= +github.com/pion/webrtc/v4 v4.1.4 h1:/gK1ACGHXQmtyVVbJFQDxNoODg4eSRiFLB7t9r9pg8M= +github.com/pion/webrtc/v4 v4.1.4/go.mod h1:Oab9npu1iZtQRMic3K3toYq5zFPvToe/QBw7dMI2ok4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -75,10 +109,16 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/packages/cli/internal/daemon/manager.go b/packages/cli/internal/daemon/manager.go new file mode 100644 index 00000000..e10d24e6 --- /dev/null +++ b/packages/cli/internal/daemon/manager.go @@ -0,0 +1,252 @@ +package daemon + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + "time" +) + +const ( + DefaultPort = 29888 // New port for unified gbox server + ServerURL = "http://localhost:29888" +) + +// Manager handles the gbox server daemon lifecycle +type Manager struct { + port int + url string +} + +// NewManager creates a new daemon manager +func NewManager() *Manager { + return &Manager{ + port: DefaultPort, + url: ServerURL, + } +} + +// EnsureServerRunning ensures the gbox server is running +// Similar to 'adb start-server' - starts server if not running +func (m *Manager) EnsureServerRunning() error { + // Check if server is already running + if m.IsServerRunning() { + return nil + } + + // Start server in background + return m.StartServer() +} + +// IsServerRunning checks if the server is running +func (m *Manager) IsServerRunning() bool { + // First check PID file + pidFile := m.getPIDFile() + if pidBytes, err := os.ReadFile(pidFile); err == nil { + var pid int + if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err == nil { + // Check if process is still alive + if isProcessAlive(pid) { + // Process exists, now check if it's responding to HTTP + if m.checkHTTPHealth() { + return true + } + } + } + // PID file exists but process is dead or not responding + os.Remove(pidFile) + } + + // Double-check with HTTP even without PID file + // (server might be running from another source) + return m.checkHTTPHealth() +} + +// checkHTTPHealth checks if server is responding to HTTP requests +func (m *Manager) checkHTTPHealth() bool { + client := &http.Client{Timeout: 500 * time.Millisecond} + resp, err := client.Get(fmt.Sprintf("%s/health", m.url)) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// StartServer starts the gbox server daemon +func (m *Manager) StartServer() error { + // Clean up any old servers first + m.CleanupOldServers() + + // Create daemon home directory + daemonHome := filepath.Join(getHomeDir(), ".gbox", "cli") + if err := os.MkdirAll(daemonHome, 0755); err != nil { + return fmt.Errorf("failed to create daemon home: %v", err) + } + + // Create log file + logFile := filepath.Join(daemonHome, "server.log") + logFd, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("failed to create log file: %v", err) + } + defer logFd.Close() + + // Start server as subprocess + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %v", err) + } + + cmd := exec.Command(exePath, "server", "--internal-daemon") + cmd.Stdout = logFd + cmd.Stderr = logFd + cmd.Env = append(os.Environ(), "GBOX_SERVER_DAEMON=1") + setSysProcAttr(cmd) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start server daemon: %v", err) + } + + pid := cmd.Process.Pid + + // Write PID file + pidFile := m.getPIDFile() + if err := os.WriteFile(pidFile, []byte(strconv.Itoa(pid)), 0644); err != nil { + log.Printf("Warning: failed to write PID file: %v", err) + } + + // Wait for server to be ready + for i := 0; i < 20; i++ { + time.Sleep(250 * time.Millisecond) + if m.checkHTTPHealth() { + log.Printf("GBox server started successfully (PID: %d)", pid) + log.Printf("Web UI available at: http://localhost:%d", m.port) + return nil + } + } + + // Server didn't start properly + return fmt.Errorf("server started but not responding on port %d", m.port) +} + +// StopServer stops the gbox server daemon +func (m *Manager) StopServer() error { + // Try graceful shutdown via API first + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Post(fmt.Sprintf("%s/api/server/shutdown", m.url), "application/json", nil) + if err == nil { + resp.Body.Close() + time.Sleep(500 * time.Millisecond) + return nil + } + + // Fall back to PID-based termination + pidFile := m.getPIDFile() + pidBytes, err := os.ReadFile(pidFile) + if err != nil { + return fmt.Errorf("server not running") + } + + var pid int + if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { + return fmt.Errorf("invalid PID file") + } + + // Send SIGTERM + if err := killProcess(pid, syscall.SIGTERM); err != nil { + os.Remove(pidFile) + return fmt.Errorf("failed to stop server: %v", err) + } + + os.Remove(pidFile) + log.Printf("GBox server stopped (PID: %d)", pid) + return nil +} + +// CleanupOldServers cleans up any old server processes +func (m *Manager) CleanupOldServers() { + // Clean up old PID files and processes + oldPidFiles := []string{ + filepath.Join(getHomeDir(), ".gbox", "device-proxy", "gbox-server.pid"), + filepath.Join(getHomeDir(), ".gbox", "device-proxy", "device-proxy.pid"), + } + + for _, pidFile := range oldPidFiles { + if pidBytes, err := os.ReadFile(pidFile); err == nil { + var pid int + if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err == nil { + killProcess(pid, syscall.SIGTERM) + } + os.Remove(pidFile) + } + } + + // Kill any stray server processes + exec.Command("pkill", "-f", "gbox.*server.*--internal-daemon").Run() + exec.Command("pkill", "-f", "device-connect start-server").Run() +} + +// getPIDFile returns the path to the PID file +func (m *Manager) getPIDFile() string { + return filepath.Join(getHomeDir(), ".gbox", "cli", "server.pid") +} + +// CallAPI makes an API call to the server +func (m *Manager) CallAPI(method, endpoint string, body interface{}, result interface{}) error { + // Ensure server is running + if err := m.EnsureServerRunning(); err != nil { + return fmt.Errorf("failed to start server: %v", err) + } + + url := fmt.Sprintf("%s%s", m.url, endpoint) + + var bodyReader io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request: %v", err) + } + bodyReader = bytes.NewReader(jsonData) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return fmt.Errorf("failed to create request: %v", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("API call failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf("failed to decode response: %v", err) + } + } + + return nil +} + +// Global instance for convenience +var DefaultManager = NewManager() \ No newline at end of file diff --git a/packages/cli/internal/daemon/manager_unix.go b/packages/cli/internal/daemon/manager_unix.go new file mode 100644 index 00000000..cd3b637e --- /dev/null +++ b/packages/cli/internal/daemon/manager_unix.go @@ -0,0 +1,34 @@ +//go:build !windows + +package daemon + +import ( + "os" + "os/exec" + "syscall" +) + +// killProcess sends a signal to a process +func killProcess(pid int, signal syscall.Signal) error { + return syscall.Kill(pid, signal) +} + +// isProcessAlive checks if a process is still running +func isProcessAlive(pid int) bool { + return syscall.Kill(pid, 0) == nil +} + +// setSysProcAttr sets platform-specific process attributes +func setSysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } +} + +// getHomeDir returns the user's home directory +func getHomeDir() string { + if home := os.Getenv("HOME"); home != "" { + return home + } + return os.Getenv("USERPROFILE") // fallback for Windows-like environments +} \ No newline at end of file diff --git a/packages/cli/internal/daemon/manager_windows.go b/packages/cli/internal/daemon/manager_windows.go new file mode 100644 index 00000000..78b65f75 --- /dev/null +++ b/packages/cli/internal/daemon/manager_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package daemon + +import ( + "os" + "os/exec" + "syscall" +) + +// killProcess sends a signal to a process on Windows +func killProcess(pid int, signal syscall.Signal) error { + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + return proc.Kill() +} + +// isProcessAlive checks if a process is still running on Windows +func isProcessAlive(pid int) bool { + _, err := os.FindProcess(pid) + if err != nil { + return false + } + // On Windows, FindProcess always succeeds for valid PIDs + // We need to actually try to open the process to check if it exists + handle, err := syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + syscall.CloseHandle(handle) + return true +} + +// setSysProcAttr sets platform-specific process attributes for Windows +func setSysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + } +} + +// getHomeDir returns the user's home directory on Windows +func getHomeDir() string { + if home := os.Getenv("USERPROFILE"); home != "" { + return home + } + return os.Getenv("HOME") // fallback +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/api/handlers.go b/packages/cli/internal/device_connect/api/handlers.go new file mode 100644 index 00000000..1a987f73 --- /dev/null +++ b/packages/cli/internal/device_connect/api/handlers.go @@ -0,0 +1,172 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + "strings" +) + +// handleDevices handles GET /api/devices +func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + devices, err := s.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 + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "devices": devices, + "onDemandEnabled": true, + }) +} + +// handleDeviceAction handles /api/devices/{id}/{action} +func (s *Server) handleDeviceAction(w http.ResponseWriter, r *http.Request) { + // Parse URL path: /api/devices/{id}/{action} + path := strings.TrimPrefix(r.URL.Path, "/api/devices/") + parts := strings.Split(path, "/") + + if len(parts) != 2 { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + deviceID := parts[0] + action := parts[1] + + switch action { + case "connect": + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s.handleDeviceConnect(w, r, deviceID) + case "disconnect": + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s.handleDeviceDisconnect(w, r, deviceID) + default: + http.Error(w, "Unknown action", http.StatusNotFound) + } +} + +// handleDeviceConnect handles POST /api/devices/{id}/connect +func (s *Server) handleDeviceConnect(w http.ResponseWriter, r *http.Request, deviceID string) { + // Create WebRTC bridge for the device + bridge, err := s.webrtcManager.CreateBridge(deviceID) + if err != nil { + log.Printf("Failed to create bridge for device %s: %v", deviceID, err) + respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + // Mark device as registered + s.deviceManager.RegisterDevice(deviceID) + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "deviceId": deviceID, + "proxyId": bridge.DeviceSerial, + "message": "Device connected successfully", + }) +} + +// handleDeviceDisconnect handles DELETE /api/devices/{id}/disconnect +func (s *Server) handleDeviceDisconnect(w http.ResponseWriter, r *http.Request, deviceID string) { + // Remove WebRTC bridge + s.webrtcManager.RemoveBridge(deviceID) + + // Mark device as unregistered + s.deviceManager.UnregisterDevice(deviceID) + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "deviceId": deviceID, + "message": "Device disconnected successfully", + }) +} + +// handleRegisterDevice handles POST /api/register-device +func (s *Server) handleRegisterDevice(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + DeviceID string `json:"deviceId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondJSON(w, http.StatusBadRequest, map[string]interface{}{ + "success": false, + "error": "Invalid request body", + }) + return + } + + bridge, err := s.webrtcManager.CreateBridge(req.DeviceID) + if err != nil { + log.Printf("Failed to create bridge for device %s: %v", req.DeviceID, err) + respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + s.deviceManager.RegisterDevice(req.DeviceID) + + log.Printf("Successfully registered device %s", req.DeviceID) + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "device_id": bridge.DeviceSerial, + "message": "Device registered successfully", + }) +} + +// handleUnregisterDevice handles POST /api/unregister-device +func (s *Server) handleUnregisterDevice(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + DeviceID string `json:"deviceId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondJSON(w, http.StatusBadRequest, map[string]interface{}{ + "success": false, + "error": "Invalid request body", + }) + return + } + + s.webrtcManager.RemoveBridge(req.DeviceID) + s.deviceManager.UnregisterDevice(req.DeviceID) + + log.Printf("Successfully unregistered device %s", req.DeviceID) + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "message": "Device unregistered successfully", + }) +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/api/server.go b/packages/cli/internal/device_connect/api/server.go new file mode 100644 index 00000000..2b728f26 --- /dev/null +++ b/packages/cli/internal/device_connect/api/server.go @@ -0,0 +1,177 @@ +package api + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/webrtc" +) + +// Server handles HTTP API and WebSocket connections +type Server struct { + port int + server *http.Server + deviceManager *device.Manager + webrtcManager *webrtc.Manager + isRunning bool +} + +// NewServer creates a new API server +func NewServer(port int) *Server { + deviceManager := device.NewManager() + + // Get ADB path for WebRTC manager + adbPath := "adb" + webrtcManager := webrtc.NewManager(adbPath) + + return &Server{ + port: port, + deviceManager: deviceManager, + webrtcManager: webrtcManager, + } +} + +// Start starts the HTTP server +func (s *Server) Start() error { + if s.isRunning { + return fmt.Errorf("server already running") + } + + // Setup routes + mux := http.NewServeMux() + + // API routes + mux.HandleFunc("/api/devices", s.handleDevices) + mux.HandleFunc("/api/devices/", s.handleDeviceAction) + mux.HandleFunc("/api/register-device", s.handleRegisterDevice) + mux.HandleFunc("/api/unregister-device", s.handleUnregisterDevice) + + // WebSocket route + mux.HandleFunc("/ws", s.handleWebSocket) + + // Static files + staticPath := s.findLiveViewStaticPath() + if staticPath != "" { + log.Printf("Serving static files from: %s", staticPath) + fs := http.FileServer(http.Dir(staticPath)) + mux.Handle("/", fs) + } else { + log.Println("Warning: Live-view static files not found") + // Return 404 for static files if not found + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + } + + // Create HTTP server + s.server = &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + + // Start server + log.Printf("Starting API server on port %d", s.port) + s.isRunning = true + + go func() { + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("Server error: %v", err) + s.isRunning = false + } + }() + + // Wait for server to start + time.Sleep(100 * time.Millisecond) + + // Test if server is accessible + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/devices", s.port)) + if err != nil { + s.isRunning = false + return fmt.Errorf("server failed to start: %w", err) + } + resp.Body.Close() + + log.Printf("API server started successfully on http://localhost:%d", s.port) + return nil +} + +// Stop stops the HTTP server +func (s *Server) Stop() error { + if !s.isRunning { + return nil + } + + log.Println("Stopping API server...") + + // Close WebRTC manager + if s.webrtcManager != nil { + s.webrtcManager.Close() + } + + // Shutdown HTTP server + if s.server != nil { + if err := s.server.Close(); err != nil { + log.Printf("Error closing server: %v", err) + } + } + + s.isRunning = false + log.Println("API server stopped") + + return nil +} + +// IsRunning returns whether the server is running +func (s *Server) IsRunning() bool { + return s.isRunning +} + +// findLiveViewStaticPath finds the live-view static files +func (s *Server) findLiveViewStaticPath() string { + searchPaths := []string{ + "../../live-view/dist/static", + "../live-view/dist/static", + "packages/live-view/dist/static", + "live-view/dist/static", + "dist/static", + "static", + } + + // Also check relative to executable + if exe, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exe) + searchPaths = append([]string{ + filepath.Join(exeDir, "static"), + filepath.Join(exeDir, "..", "live-view", "dist", "static"), + filepath.Join(exeDir, "..", "..", "live-view", "dist", "static"), + }, searchPaths...) + } + + for _, path := range searchPaths { + if info, err := os.Stat(path); err == nil && info.IsDir() { + absPath, _ := filepath.Abs(path) + return absPath + } + } + + return "" +} + +// respondJSON sends a JSON response +func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + // Use json encoder to write response + if err := json.NewEncoder(w).Encode(data); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + } +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/api/websocket.go b/packages/cli/internal/device_connect/api/websocket.go new file mode 100644 index 00000000..97766b9a --- /dev/null +++ b/packages/cli/internal/device_connect/api/websocket.go @@ -0,0 +1,323 @@ +package api + +import ( + "fmt" + "log" + "net/http" + + "github.com/gorilla/websocket" + "github.com/pion/webrtc/v4" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins for now + }, +} + +// handleWebSocket handles WebSocket connections for WebRTC signaling +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("Failed to upgrade WebSocket: %v", err) + return + } + defer conn.Close() + + log.Println("WebSocket connection established") + + for { + var msg map[string]interface{} + if err := conn.ReadJSON(&msg); err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("WebSocket read error: %v", err) + } + break + } + + msgType, ok := msg["type"].(string) + if !ok { + continue + } + + switch msgType { + case "connect": + s.handleWebSocketConnect(conn, msg) + case "offer": + s.handleWebSocketOffer(conn, msg) + case "ice-candidate": + s.handleWebSocketICECandidate(conn, msg) + case "disconnect": + s.handleWebSocketDisconnect(conn, msg) + case "touch": + s.handleWebSocketTouch(conn, msg) + case "key": + s.handleWebSocketKey(conn, msg) + case "scroll": + s.handleWebSocketScroll(conn, msg) + } + } +} + +// handleWebSocketConnect handles WebSocket connect message +func (s *Server) handleWebSocketConnect(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": "Device serial required", + }) + return + } + + bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + if !exists { + var err error + bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + if err != nil { + log.Printf("Failed to create bridge: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": err.Error(), + }) + return + } + } + + bridge.WSConnection = conn + + conn.WriteJSON(map[string]interface{}{ + "type": "connected", + "deviceSerial": deviceSerial, + }) +} + +// handleWebSocketOffer handles WebRTC offer +func (s *Server) handleWebSocketOffer(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + return + } + + offerData, ok := msg["offer"].(map[string]interface{}) + if !ok { + return + } + + sdp, ok := offerData["sdp"].(string) + if !ok { + return + } + + // Get or create bridge for the device + bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + if !exists { + log.Printf("Bridge not found for device %s, creating new bridge", deviceSerial) + var err error + bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + if err != nil { + log.Printf("Failed to create bridge: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": fmt.Sprintf("Failed to connect to device: %v", err), + }) + return + } + } + + // Check signaling state - only recreate if truly necessary + signalingState := bridge.WebRTCConn.SignalingState() + connState := bridge.WebRTCConn.ConnectionState() + + log.Printf("Bridge state for device %s: signaling=%s, connection=%s", deviceSerial, signalingState, connState) + + // Only recreate bridge if connection is truly closed or failed + if connState == webrtc.PeerConnectionStateClosed || connState == webrtc.PeerConnectionStateFailed { + log.Printf("WebRTC connection is %s for device %s, recreating bridge", connState, deviceSerial) + s.webrtcManager.RemoveBridge(deviceSerial) + + // Create new bridge + var err error + bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + if err != nil { + log.Printf("Failed to recreate bridge: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": fmt.Sprintf("Failed to reconnect to device: %v", err), + }) + return + } + } else if signalingState == webrtc.SignalingStateClosed { + // Only recreate if signaling is closed but connection is still active + log.Printf("Signaling state is closed for device %s, recreating bridge", deviceSerial) + s.webrtcManager.RemoveBridge(deviceSerial) + + var err error + bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + if err != nil { + log.Printf("Failed to recreate bridge: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": fmt.Sprintf("Failed to reset connection: %v", err), + }) + return + } + } + + offer := webrtc.SessionDescription{ + Type: webrtc.SDPTypeOffer, + SDP: sdp, + } + + if err := bridge.WebRTCConn.SetRemoteDescription(offer); err != nil { + log.Printf("Failed to set remote description: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": err.Error(), + }) + return + } + + answer, err := bridge.WebRTCConn.CreateAnswer(nil) + if err != nil { + log.Printf("Failed to create answer: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": err.Error(), + }) + return + } + + if err := bridge.WebRTCConn.SetLocalDescription(answer); err != nil { + log.Printf("Failed to set local description: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": err.Error(), + }) + return + } + + conn.WriteJSON(map[string]interface{}{ + "type": "answer", + "answer": map[string]interface{}{ + "type": "answer", + "sdp": answer.SDP, + }, + }) + + // Set up ICE candidate handler + bridge.WebRTCConn.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate == nil { + return + } + + candidateJSON := candidate.ToJSON() + conn.WriteJSON(map[string]interface{}{ + "type": "ice-candidate", + "candidate": map[string]interface{}{ + "candidate": candidateJSON.Candidate, + "sdpMLineIndex": candidateJSON.SDPMLineIndex, + "sdpMid": candidateJSON.SDPMid, + }, + }) + }) + + // Device info is not needed by frontend, video dimensions will be available through video track +} + +// handleWebSocketICECandidate handles ICE candidate +func (s *Server) handleWebSocketICECandidate(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + return + } + + candidateData, ok := msg["candidate"].(map[string]interface{}) + if !ok { + return + } + + bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + if !exists { + return + } + + candidate := webrtc.ICECandidateInit{ + Candidate: candidateData["candidate"].(string), + } + + if sdpMLineIndex, ok := candidateData["sdpMLineIndex"].(float64); ok { + index := uint16(sdpMLineIndex) + candidate.SDPMLineIndex = &index + } + + if sdpMid, ok := candidateData["sdpMid"].(string); ok { + candidate.SDPMid = &sdpMid + } + + if err := bridge.WebRTCConn.AddICECandidate(candidate); err != nil { + log.Printf("Failed to add ICE candidate: %v", err) + } +} + +// handleWebSocketDisconnect handles disconnect message +func (s *Server) handleWebSocketDisconnect(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + return + } + + s.webrtcManager.RemoveBridge(deviceSerial) + + conn.WriteJSON(map[string]interface{}{ + "type": "disconnected", + }) +} + +// handleWebSocketTouch handles touch events +func (s *Server) handleWebSocketTouch(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + return + } + + bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + if !exists { + log.Printf("Bridge not found for device %s", deviceSerial) + return + } + + bridge.HandleTouchEvent(msg) +} + +// handleWebSocketKey handles key events +func (s *Server) handleWebSocketKey(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + return + } + + bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + if !exists { + log.Printf("Bridge not found for device %s", deviceSerial) + return + } + + bridge.HandleKeyEvent(msg) +} + +// handleWebSocketScroll handles scroll events +func (s *Server) handleWebSocketScroll(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + return + } + + bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + if !exists { + log.Printf("Bridge not found for device %s", deviceSerial) + return + } + + bridge.HandleScrollEvent(msg) +} diff --git a/packages/cli/internal/device_connect/daemon_native.go b/packages/cli/internal/device_connect/daemon_native.go new file mode 100644 index 00000000..dc34a9c4 --- /dev/null +++ b/packages/cli/internal/device_connect/daemon_native.go @@ -0,0 +1,182 @@ +//go:build !windows + +package device_connect + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + "time" + + "github.com/babelcloud/gbox/packages/cli/config" +) + +// serverInstance holds the global server instance +var serverInstance *Server + +// StartNativeDeviceProxyService starts the integrated scrcpy server +func StartNativeDeviceProxyService() error { + // Create device proxy home directory + deviceProxyHome := config.GetDeviceProxyHome() + if err := os.MkdirAll(deviceProxyHome, 0755); err != nil { + return fmt.Errorf("failed to create device proxy home directory: %v", err) + } + + // Check if already running + pidFile := filepath.Join(deviceProxyHome, "device-proxy.pid") + if pidBytes, err := os.ReadFile(pidFile); err == nil { + var pid int + if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err == nil { + // Check if process is still running + if err := syscall.Kill(pid, 0); err == nil { + return fmt.Errorf("device proxy service already running with PID %d", pid) + } + } + // Remove stale PID file + os.Remove(pidFile) + } + + // Fork the process to run in background + if os.Getenv("GBOX_DEVICE_PROXY_DAEMON") != "1" { + // Create log file + logFile := filepath.Join(deviceProxyHome, "device-proxy.log") + logFd, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("failed to create log file: %v", err) + } + defer logFd.Close() + + // Start the server as a subprocess using exec.Command + cmd := exec.Command(os.Args[0], "device-connect", "start-server") + cmd.Env = append(os.Environ(), "GBOX_DEVICE_PROXY_DAEMON=1") + cmd.Stdout = logFd + cmd.Stderr = logFd + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start daemon: %v", err) + } + + pid := cmd.Process.Pid + + // Write PID file + if err := os.WriteFile(pidFile, []byte(strconv.Itoa(pid)), 0644); err != nil { + return fmt.Errorf("failed to write PID file: %v", err) + } + + log.Printf("Started device proxy service with PID %d", pid) + + // Wait a bit to ensure server starts + time.Sleep(500 * time.Millisecond) + + // Verify server is responding + client := NewClient(DefaultURL) + for i := 0; i < 10; i++ { + if _, _, err := client.IsServiceRunning(); err == nil { + return nil + } + time.Sleep(500 * time.Millisecond) + } + + // If we get here, server didn't start properly + log.Printf("Warning: Server started but not responding on port %d", DefaultPort) + return nil + } + + // This is the daemon process + // Start the integrated device connect server + server := NewServer(DefaultPort) + serverInstance = server + + if err := server.Start(); err != nil { + return fmt.Errorf("failed to start device connect server: %v", err) + } + + // Write our PID + if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())), 0644); err != nil { + log.Printf("Warning: failed to write PID file: %v", err) + } + + // Keep the process running + select {} +} + +// StopNativeDeviceProxyService stops the integrated scrcpy server +func StopNativeDeviceProxyService() error { + deviceProxyHome := config.GetDeviceProxyHome() + pidFile := filepath.Join(deviceProxyHome, "device-proxy.pid") + + // Read PID from file + pidBytes, err := os.ReadFile(pidFile) + if err != nil { + return fmt.Errorf("device proxy service not running") + } + + var pid int + if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { + return fmt.Errorf("invalid PID file") + } + + // Send SIGTERM to the process + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + // Process might already be dead + os.Remove(pidFile) + return fmt.Errorf("failed to stop process: %v", err) + } + + // Remove PID file + os.Remove(pidFile) + + log.Printf("Stopped device proxy service (PID %d)", pid) + return nil +} + +// IsNativeServiceRunning checks if the native service is running +func IsNativeServiceRunning() (bool, error) { + // First check if PID file exists + deviceProxyHome := config.GetDeviceProxyHome() + pidFile := filepath.Join(deviceProxyHome, "device-proxy.pid") + + if _, err := os.Stat(pidFile); os.IsNotExist(err) { + return false, nil + } + + // Read PID from file + pidBytes, err := os.ReadFile(pidFile) + if err != nil { + return false, nil + } + + var pid int + if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { + return false, nil + } + + // Check if process is still running + if err := syscall.Kill(pid, 0); err != nil { + // Process is not running, remove PID file + os.Remove(pidFile) + return false, nil + } + + // Try to check service status via API + client := NewClient(DefaultURL) + running, onDemandEnabled, err := client.IsServiceRunning() + if err != nil { + // If API check fails but process exists, assume it's starting up + return true, nil + } + + // Check if onDemandEnabled is false and warn user + if running && !onDemandEnabled { + fmt.Println("Warning: Device proxy service is running with automatic registration enabled.") + } + + return running, nil +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/device/connection.go b/packages/cli/internal/device_connect/device/connection.go new file mode 100644 index 00000000..7f98a6fc --- /dev/null +++ b/packages/cli/internal/device_connect/device/connection.go @@ -0,0 +1,262 @@ +package device + +import ( + "fmt" + "log" + "net" + "os" + "os/exec" + "path/filepath" + "time" +) + +// ScrcpyConnection handles the actual scrcpy server connection +type ScrcpyConnection struct { + deviceSerial string + scid uint32 + adbPath string + serverPath string + conn net.Conn + Listener net.Listener // Made public to match scrcpy-proxy + serverCmd *exec.Cmd +} + +// NewScrcpyConnection creates a new scrcpy connection handler +func NewScrcpyConnection(deviceSerial string, scid uint32) *ScrcpyConnection { + // Find adb path + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" // Fallback to PATH + } + + // Find scrcpy-server.jar + serverPath := findScrcpyServerJar() + if serverPath == "" { + log.Printf("Warning: scrcpy-server.jar not found, will try default location") + serverPath = "/data/local/tmp/scrcpy-server.jar" + } + + return &ScrcpyConnection{ + deviceSerial: deviceSerial, + scid: scid, + adbPath: adbPath, + serverPath: serverPath, + } +} + +// Connect establishes connection to scrcpy server on device +func (sc *ScrcpyConnection) Connect() (net.Conn, error) { + log.Printf("Starting scrcpy connection for device %s on port %d", sc.deviceSerial, sc.scid) + + // 1. Push server file to device + if err := sc.pushServerFile(); err != nil { + return nil, fmt.Errorf("failed to push server file: %w", err) + } + + // 2. Setup reverse port forwarding + if err := sc.setupReversePortForward(); err != nil { + return nil, fmt.Errorf("failed to setup reverse port forward: %w", err) + } + + // 3. Start listener for scrcpy server connection + listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", sc.scid)) + if err != nil { + return nil, fmt.Errorf("failed to start listener on port %d: %w", sc.scid, err) + } + sc.Listener = listener + + // 4. Start scrcpy server on device + if err := sc.startScrcpyServer(); err != nil { + listener.Close() + return nil, fmt.Errorf("failed to start scrcpy server: %w", err) + } + + // 5. Accept connection from scrcpy server + log.Printf("Waiting for scrcpy server to connect...") + + // Set deadline for accept + if err := listener.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + listener.Close() + return nil, fmt.Errorf("failed to set deadline: %w", err) + } + + conn, err := listener.Accept() + if err != nil { + listener.Close() + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + sc.killScrcpyServer() + return nil, fmt.Errorf("timeout waiting for scrcpy server") + } + return nil, fmt.Errorf("failed to accept connection: %w", err) + } + + // Clear deadline for future accepts + listener.(*net.TCPListener).SetDeadline(time.Time{}) + + log.Printf("Scrcpy server connected successfully") + sc.conn = conn + return conn, nil +} + +// pushServerFile pushes scrcpy-server.jar to device +func (sc *ScrcpyConnection) pushServerFile() error { + // Check if local server file exists + if sc.serverPath != "" && sc.serverPath != "/data/local/tmp/scrcpy-server.jar" { + // Check if file exists locally + if _, err := os.Stat(sc.serverPath); err == nil { + log.Printf("Pushing scrcpy-server.jar to device...") + cmd := exec.Command(sc.adbPath, "-s", sc.deviceSerial, "push", sc.serverPath, "/data/local/tmp/scrcpy-server.jar") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to push server: %s", output) + } + log.Printf("Server file pushed successfully") + } + } + + // Verify server exists on device + cmd := exec.Command(sc.adbPath, "-s", sc.deviceSerial, "shell", "ls", "/data/local/tmp/scrcpy-server.jar") + if err := cmd.Run(); err != nil { + return fmt.Errorf("scrcpy-server.jar not found on device") + } + + return nil +} + +// setupReversePortForward sets up adb reverse port forwarding +func (sc *ScrcpyConnection) setupReversePortForward() error { + // Clean up any existing reverse forward + cleanCmd := exec.Command(sc.adbPath, "-s", sc.deviceSerial, "reverse", "--remove", fmt.Sprintf("localabstract:scrcpy_%08x", sc.scid)) + cleanCmd.Run() // Ignore error if doesn't exist + + // Setup new reverse forward + log.Printf("Setting up reverse port forward: scrcpy_%08x -> tcp:%d", sc.scid, sc.scid) + cmd := exec.Command(sc.adbPath, "-s", sc.deviceSerial, "reverse", + fmt.Sprintf("localabstract:scrcpy_%08x", sc.scid), + fmt.Sprintf("tcp:%d", sc.scid)) + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to setup reverse forward: %s", output) + } + + return nil +} + +// startScrcpyServer starts the scrcpy server process on device +func (sc *ScrcpyConnection) startScrcpyServer() error { + // Kill any existing scrcpy server + sc.killScrcpyServer() + time.Sleep(200 * time.Millisecond) + + // Build scrcpy server command + scidHex := fmt.Sprintf("%08x", sc.scid) + + cmd := exec.Command(sc.adbPath, "-s", sc.deviceSerial, "shell", + "CLASSPATH=/data/local/tmp/scrcpy-server.jar", + "app_process", "/", "com.genymobile.scrcpy.Server", + "3.3.1", // Server version - must match the downloaded jar + fmt.Sprintf("scid=%s", scidHex), + "video=true", + "audio=true", + "control=true", + "cleanup=true", + "log_level=verbose", // Enable verbose logging to debug scroll issues + "video_codec_options=i-frame-interval=1", // Force keyframe every 1 second to prevent video freezing + "video_bit_rate=12000000", // 12 Mbps for better quality + "max_fps=60", // 60 FPS for smoother video + "video_encoder=OMX.qcom.video.encoder.avc", // Use Qualcomm hardware encoder (more compatible) + "audio_codec=opus", // Use Opus for better audio quality + "audio_bit_rate=128000", // 128 kbps audio + ) + + log.Printf("Starting scrcpy server with command: %s", cmd.String()) + + // Start the command + sc.serverCmd = cmd + + // Capture output for debugging + cmd.Stdout = &logWriter{prefix: "[scrcpy-out]"} + cmd.Stderr = &logWriter{prefix: "[scrcpy-err]"} + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start scrcpy server: %w", err) + } + + // Give server time to start + time.Sleep(500 * time.Millisecond) + + return nil +} + +// killScrcpyServer kills any running scrcpy server on device +func (sc *ScrcpyConnection) killScrcpyServer() { + // Kill by process name + cmd := exec.Command(sc.adbPath, "-s", sc.deviceSerial, "shell", "pkill", "-f", "scrcpy.Server") + cmd.Run() + + // Also kill our tracked process if exists + if sc.serverCmd != nil && sc.serverCmd.Process != nil { + sc.serverCmd.Process.Kill() + sc.serverCmd = nil + } +} + +// Close closes the scrcpy connection +func (sc *ScrcpyConnection) Close() error { + log.Printf("Closing scrcpy connection for device %s", sc.deviceSerial) + + // Close connection + if sc.conn != nil { + sc.conn.Close() + } + + // Close listener + if sc.Listener != nil { + sc.Listener.Close() + } + + // Kill server process + sc.killScrcpyServer() + + // Clean up reverse forward + cmd := exec.Command(sc.adbPath, "-s", sc.deviceSerial, "reverse", "--remove", fmt.Sprintf("localabstract:scrcpy_%08x", sc.scid)) + cmd.Run() + + return nil +} + +// findScrcpyServerJar finds the scrcpy-server.jar file +func findScrcpyServerJar() string { + // Common locations to check + locations := []string{ + // In project assets directory (primary location) + "./assets/scrcpy-server.jar", + "../cli/assets/scrcpy-server.jar", + "../../packages/cli/assets/scrcpy-server.jar", + // In home directory + filepath.Join(os.Getenv("HOME"), ".gbox", "scrcpy-server.jar"), + // In scrcpy installation + "/usr/local/share/scrcpy/scrcpy-server", + "/opt/homebrew/share/scrcpy/scrcpy-server", + "/usr/share/scrcpy/scrcpy-server", + } + + for _, path := range locations { + if _, err := os.Stat(path); err == nil { + absPath, _ := filepath.Abs(path) + log.Printf("Found scrcpy-server.jar at: %s", absPath) + return absPath + } + } + + return "" +} + +// logWriter implements io.Writer for logging +type logWriter struct { + prefix string +} + +func (w *logWriter) Write(p []byte) (n int, err error) { + log.Printf("%s %s", w.prefix, string(p)) + return len(p), nil +} diff --git a/packages/cli/internal/device_connect/device/manager.go b/packages/cli/internal/device_connect/device/manager.go new file mode 100644 index 00000000..12335e05 --- /dev/null +++ b/packages/cli/internal/device_connect/device/manager.go @@ -0,0 +1,148 @@ +package device + +import ( + "fmt" + "os/exec" + "strings" + "sync" +) + +// Manager manages Android devices +type Manager struct { + adbPath string + devices map[string]*DeviceInfo + mu sync.RWMutex +} + +// DeviceInfo contains device information +type DeviceInfo struct { + Serial string + State string + Model string + Manufacturer string + ConnectionType string + IsRegistered bool +} + +// NewManager creates a new device manager +func NewManager() *Manager { + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + + return &Manager{ + adbPath: adbPath, + devices: make(map[string]*DeviceInfo), + } +} + +// GetDevices returns list of connected Android devices +func (m *Manager) GetDevices() ([]map[string]interface{}, error) { + cmd := exec.Command(m.adbPath, "devices", "-l") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to run adb devices: %w", err) + } + + lines := strings.Split(string(output), "\n") + devices := []map[string]interface{}{} + + for _, line := range lines[1:] { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + serial := parts[0] + state := parts[1] + + if state != "device" { + continue + } + + device := map[string]interface{}{ + "id": serial, + "udid": serial, + "state": state, + "ro.serialno": serial, + "connectionType": "usb", + "isRegistrable": false, + } + + // Parse additional properties + if strings.Contains(line, "model:") { + if idx := strings.Index(line, "model:"); idx != -1 { + modelPart := line[idx+6:] + if spaceIdx := strings.Index(modelPart, " "); spaceIdx != -1 { + device["ro.product.model"] = modelPart[:spaceIdx] + } else { + device["ro.product.model"] = modelPart + } + } + } + + if strings.Contains(line, "device:") { + if idx := strings.Index(line, "device:"); idx != -1 { + devicePart := line[idx+7:] + if spaceIdx := strings.Index(devicePart, " "); spaceIdx != -1 { + device["ro.product.manufacturer"] = devicePart[:spaceIdx] + } + } + } + + if strings.Contains(serial, ":") { + device["connectionType"] = "ip" + } + + // Check if device is registered + m.mu.RLock() + if info, exists := m.devices[serial]; exists { + device["isRegistrable"] = info.IsRegistered + } + m.mu.RUnlock() + + devices = append(devices, device) + } + + return devices, nil +} + +// RegisterDevice marks a device as registered +func (m *Manager) RegisterDevice(serial string) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.devices[serial] == nil { + m.devices[serial] = &DeviceInfo{ + Serial: serial, + } + } + m.devices[serial].IsRegistered = true +} + +// UnregisterDevice marks a device as unregistered +func (m *Manager) UnregisterDevice(serial string) { + m.mu.Lock() + defer m.mu.Unlock() + + if info, exists := m.devices[serial]; exists { + info.IsRegistered = false + } +} + +// IsDeviceRegistered checks if a device is registered +func (m *Manager) IsDeviceRegistered(serial string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + + if info, exists := m.devices[serial]; exists { + return info.IsRegistered + } + return false +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/device/media.go b/packages/cli/internal/device_connect/device/media.go new file mode 100644 index 00000000..b492e260 --- /dev/null +++ b/packages/cli/internal/device_connect/device/media.go @@ -0,0 +1,158 @@ +package device + +import ( + "encoding/binary" + "fmt" + "io" + "strings" +) + +// DeviceMeta contains device metadata +type DeviceMeta struct { + DeviceName string + Width uint32 + Height uint32 +} + +// VideoPacket represents a video packet +type VideoPacket struct { + PTS uint64 + Data []byte + IsConfig bool + IsKeyFrame bool +} + +// AudioPacket represents an audio packet +type AudioPacket struct { + PTS uint64 + Data []byte +} + +// ControlMessage represents a control message +type ControlMessage struct { + Type uint8 + Sequence uint32 + Data []byte +} + +// ReadDeviceMeta reads device metadata from connection +func ReadDeviceMeta(conn io.Reader) (*DeviceMeta, error) { + // According to scrcpy protocol, device metadata only contains device name (64 bytes) + const deviceNameFieldLength = 64 + nameBytes := make([]byte, deviceNameFieldLength) + if _, err := io.ReadFull(conn, nameBytes); err != nil { + return nil, fmt.Errorf("failed to read device name: %w", err) + } + + // Remove null bytes and get device name + deviceName := strings.TrimRight(string(nameBytes), "\x00") + + return &DeviceMeta{ + DeviceName: deviceName, + Width: 0, // Will be determined from video stream + Height: 0, // Will be determined from video stream + }, nil +} + +// ReadVideoPacket reads a video packet from the stream +func ReadVideoPacket(reader io.Reader) (*VideoPacket, error) { + // Read packet header (8 bytes: PTS) + header := make([]byte, 8) + if _, err := io.ReadFull(reader, header); err != nil { + if err == io.EOF { + return nil, err + } + return nil, fmt.Errorf("failed to read packet header: %w", err) + } + + pts := binary.BigEndian.Uint64(header) + + // Read packet size (4 bytes) + sizeBuf := make([]byte, 4) + if _, err := io.ReadFull(reader, sizeBuf); err != nil { + return nil, fmt.Errorf("failed to read packet size: %w", err) + } + + packetSize := binary.BigEndian.Uint32(sizeBuf) + + // Sanity check + if packetSize > 10*1024*1024 { // 10MB max + return nil, fmt.Errorf("packet size too large: %d", packetSize) + } + + // Read packet data + packetData := make([]byte, packetSize) + if _, err := io.ReadFull(reader, packetData); err != nil { + return nil, fmt.Errorf("failed to read packet data: %w", err) + } + + // Detect config and keyframe packets + isConfig := false + isKeyFrame := false + + if len(packetData) > 4 { + // Check NAL unit type for H.264 + nalType := packetData[4] & 0x1F + if nalType == 7 || nalType == 8 { // SPS or PPS + isConfig = true + } else if nalType == 5 { // IDR frame + isKeyFrame = true + } + + // Also check if it starts with 0x00000001 followed by 0x67 (SPS) or 0x68 (PPS) + if len(packetData) > 5 && + packetData[0] == 0 && packetData[1] == 0 && + packetData[2] == 0 && packetData[3] == 1 { + if packetData[4] == 0x67 || packetData[4] == 0x68 { + isConfig = true + } else if packetData[4] == 0x65 { + isKeyFrame = true + } + } + } + + return &VideoPacket{ + PTS: pts, + Data: packetData, + IsConfig: isConfig, + IsKeyFrame: isKeyFrame, + }, nil +} + +// ReadAudioPacket reads an audio packet from the stream +func ReadAudioPacket(reader io.Reader) (*AudioPacket, error) { + // Read packet header (8 bytes: PTS) + header := make([]byte, 8) + if _, err := io.ReadFull(reader, header); err != nil { + if err == io.EOF { + return nil, err + } + return nil, fmt.Errorf("failed to read packet header: %w", err) + } + + pts := binary.BigEndian.Uint64(header) + + // Read packet size (4 bytes) + sizeBuf := make([]byte, 4) + if _, err := io.ReadFull(reader, sizeBuf); err != nil { + return nil, fmt.Errorf("failed to read packet size: %w", err) + } + + packetSize := binary.BigEndian.Uint32(sizeBuf) + + // Sanity check + if packetSize > 1024*1024 { // 1MB max for audio + return nil, fmt.Errorf("audio packet size too large: %d", packetSize) + } + + // Read packet data + packetData := make([]byte, packetSize) + if _, err := io.ReadFull(reader, packetData); err != nil { + return nil, fmt.Errorf("failed to read packet data: %w", err) + } + + return &AudioPacket{ + PTS: pts, + Data: packetData, + }, nil +} diff --git a/packages/cli/internal/device_connect/protocol/control.go b/packages/cli/internal/device_connect/protocol/control.go new file mode 100644 index 00000000..00e68d23 --- /dev/null +++ b/packages/cli/internal/device_connect/protocol/control.go @@ -0,0 +1,239 @@ +package protocol + +import ( + "encoding/binary" + "fmt" +) + +// Control message type aliases for compatibility +const ( + ControlMsgTypeInjectTouch = ControlMsgTypeInjectTouchEvent + ControlMsgTypeInjectScroll = ControlMsgTypeInjectScrollEvent + + // Touch action constants + TouchActionDown = 0 + TouchActionUp = 1 + TouchActionMove = 2 +) + +// KeyEvent represents a keyboard event +type KeyEvent struct { + Action string + Keycode int + MetaState int + Repeat int +} + +// TouchEvent represents a touch/mouse event +type TouchEvent struct { + Action string + X float64 + Y float64 + Pressure float64 + PointerID int +} + +// ScrollEvent represents a scroll event +type ScrollEvent struct { + X float64 + Y float64 + HScroll float64 + VScroll float64 +} + +// EncodeKeyEvent encodes a key event for scrcpy protocol (like scrcpy-proxy) +func EncodeKeyEvent(event KeyEvent) []byte { + buf := make([]byte, 0, 16) + + // Action (1 byte) + var actionCode byte + if event.Action == "up" { + actionCode = 1 + } else { + actionCode = 0 + } + buf = append(buf, actionCode) + + // Keycode (4 bytes) + keyBytes := make([]byte, 4) + binary.BigEndian.PutUint32(keyBytes, uint32(event.Keycode)) + buf = append(buf, keyBytes...) + + // Repeat (4 bytes) + repeatBytes := make([]byte, 4) + binary.BigEndian.PutUint32(repeatBytes, uint32(event.Repeat)) + buf = append(buf, repeatBytes...) + + // Meta state (4 bytes) + metaBytes := make([]byte, 4) + binary.BigEndian.PutUint32(metaBytes, uint32(event.MetaState)) + buf = append(buf, metaBytes...) + + return buf +} + +// EncodeTextEvent encodes a text event for scrcpy protocol +// Based on scrcpy official implementation in control_msg.c +// Note: This function returns only the message data (without message type) +func EncodeTextEvent(text string) []byte { + textBytes := []byte(text) + textLen := len(textBytes) + + // Message format: [length][text] + buf := make([]byte, 4+textLen) + + // Text length (4 bytes, big endian) + binary.BigEndian.PutUint32(buf[0:4], uint32(textLen)) + + // Text content + copy(buf[4:], textBytes) + + return buf +} + +// EncodeTouchEvent encodes a touch event for scrcpy protocol (exactly like scrcpy-proxy) +func EncodeTouchEvent(event TouchEvent, screenWidth, screenHeight int) []byte { + buf := make([]byte, 0, 32) + + // Action (1 byte) + var actionCode byte + switch event.Action { + case "down": + actionCode = 0 // ACTION_DOWN + case "up": + actionCode = 1 // ACTION_UP + case "move": + actionCode = 2 // ACTION_MOVE + } + buf = append(buf, actionCode) + + // Pointer ID (8 bytes) - always 0 like scrcpy-proxy + ptrBytes := make([]byte, 8) + binary.BigEndian.PutUint64(ptrBytes, 0) + buf = append(buf, ptrBytes...) + + // Position structure: + // - x (4 bytes) - convert normalized coordinates to screen pixels + // - y (4 bytes) - convert normalized coordinates to screen pixels + // - screenWidth (2 bytes) + // - screenHeight (2 bytes) + posBytes := make([]byte, 12) + screenX := uint32(event.X * float64(screenWidth)) + screenY := uint32(event.Y * float64(screenHeight)) + binary.BigEndian.PutUint32(posBytes[0:4], screenX) + binary.BigEndian.PutUint32(posBytes[4:8], screenY) + // Screen dimensions - use actual device screen size + binary.BigEndian.PutUint16(posBytes[8:10], uint16(screenWidth)) + binary.BigEndian.PutUint16(posBytes[10:12], uint16(screenHeight)) + buf = append(buf, posBytes...) + + // Pressure (16-bit, 0xFFFF = 1.0) - always 1.0 like scrcpy-proxy + pressureBytes := make([]byte, 2) + binary.BigEndian.PutUint16(pressureBytes, 0xFFFF) // 1.0 pressure + buf = append(buf, pressureBytes...) + + // Action button (32-bit) - always 1 like scrcpy-proxy + actionButtonBytes := make([]byte, 4) + binary.BigEndian.PutUint32(actionButtonBytes, 1) // Primary button + buf = append(buf, actionButtonBytes...) + + // Buttons (32-bit) - always 1 like scrcpy-proxy + buttonBytes := make([]byte, 4) + binary.BigEndian.PutUint32(buttonBytes, 1) // Primary button pressed + buf = append(buf, buttonBytes...) + + return buf +} + +// EncodeScrollEvent encodes a scroll event for scrcpy protocol +// Based on scrcpy official implementation in control_msg.c +// Note: This function returns only the message data (without message type) +func EncodeScrollEvent(event ScrollEvent, screenWidth, screenHeight int) []byte { + buf := make([]byte, 20) + + // Position (exactly like scrcpy's write_position function) + // Following the exact layout from app/src/control_msg.c:write_position() + screenX := uint32(event.X * float64(screenWidth)) + screenY := uint32(event.Y * float64(screenHeight)) + + // write_position writes to buf[0], which contains: + // buf[0:4] = x coordinate (4 bytes, big endian) + // buf[4:8] = y coordinate (4 bytes, big endian) + // buf[8:10] = screen width (2 bytes, big endian) + // buf[10:12] = screen height (2 bytes, big endian) + binary.BigEndian.PutUint32(buf[0:4], screenX) + binary.BigEndian.PutUint32(buf[4:8], screenY) + binary.BigEndian.PutUint16(buf[8:10], uint16(screenWidth)) + binary.BigEndian.PutUint16(buf[10:12], uint16(screenHeight)) + + // Scroll amounts - following scrcpy's normalization + // Accept values in the range [-16, 16], normalize to [-1, 1] + hScrollNorm := event.HScroll / 16.0 + if hScrollNorm > 1.0 { + hScrollNorm = 1.0 + } else if hScrollNorm < -1.0 { + hScrollNorm = -1.0 + } + + vScrollNorm := event.VScroll / 16.0 + if vScrollNorm > 1.0 { + vScrollNorm = 1.0 + } else if vScrollNorm < -1.0 { + vScrollNorm = -1.0 + } + + // Convert to 16-bit fixed point (exactly like scrcpy's sc_float_to_i16fp) + // scrcpy uses: int32_t i = f * 0x1p15f; // 2^15 + // Then clamps to [0x7fff, -0x8000] range + hScrollInt32 := int32(hScrollNorm * 32768) // 2^15 + vScrollInt32 := int32(vScrollNorm * 32768) // 2^15 + + // Clamp to scrcpy's range: [0x7fff, -0x8000] + if hScrollInt32 >= 0x7fff { + hScrollInt32 = 0x7fff + } + if vScrollInt32 >= 0x7fff { + vScrollInt32 = 0x7fff + } + + // Convert to int16 (this handles the two's complement conversion automatically) + hScroll := int16(hScrollInt32) + vScroll := int16(vScrollInt32) + + // Convert to uint16 exactly like scrcpy does: (uint16_t) hscroll + // This preserves the two's complement representation + hScrollUint16 := uint16(hScroll) + vScrollUint16 := uint16(vScroll) + + binary.BigEndian.PutUint16(buf[12:14], hScrollUint16) + binary.BigEndian.PutUint16(buf[14:16], vScrollUint16) + + // Buttons (none) + binary.BigEndian.PutUint32(buf[16:20], 0) + + // Debug logging + fmt.Printf("EncodeScrollEvent: input=(%.2f, %.2f, %.2f, %.2f), screen=%dx%d\n", + event.X, event.Y, event.HScroll, event.VScroll, screenWidth, screenHeight) + fmt.Printf("EncodeScrollEvent: calculated position=(%d, %d), screen_size=(%d, %d)\n", + screenX, screenY, screenWidth, screenHeight) + fmt.Printf("EncodeScrollEvent: normalized=(%.2f, %.2f), int32=(%d, %d), int16=(%d, %d)\n", + hScrollNorm, vScrollNorm, hScrollInt32, vScrollInt32, hScroll, vScroll) + fmt.Printf("EncodeScrollEvent: uint16_values=(%d, %d), encoded buffer: %v\n", + hScrollUint16, vScrollUint16, buf) + + // Detailed buffer analysis + fmt.Printf("EncodeScrollEvent: buffer breakdown:\n") + fmt.Printf(" [0:4] = %v (x=%d)\n", buf[0:4], binary.BigEndian.Uint32(buf[0:4])) + fmt.Printf(" [4:8] = %v (y=%d)\n", buf[4:8], binary.BigEndian.Uint32(buf[4:8])) + fmt.Printf(" [8:10] = %v (width=%d)\n", buf[8:10], binary.BigEndian.Uint16(buf[8:10])) + fmt.Printf(" [10:12] = %v (height=%d)\n", buf[10:12], binary.BigEndian.Uint16(buf[10:12])) + fmt.Printf(" [12:14] = %v (hScroll=%d)\n", buf[12:14], binary.BigEndian.Uint16(buf[12:14])) + fmt.Printf(" [14:16] = %v (vScroll=%d)\n", buf[14:16], binary.BigEndian.Uint16(buf[14:16])) + fmt.Printf(" [16:20] = %v (buttons=%d)\n", buf[16:20], binary.BigEndian.Uint32(buf[16:20])) + + // Compare with scrcpy test case format + fmt.Printf("EncodeScrollEvent: scrcpy test comparison - expecting position=(%d, %d), screen=(%d, %d)\n", + screenX, screenY, screenWidth, screenHeight) + + return buf +} diff --git a/packages/cli/internal/device_connect/protocol/scrcpy.go b/packages/cli/internal/device_connect/protocol/scrcpy.go new file mode 100644 index 00000000..b3fcd8fe --- /dev/null +++ b/packages/cli/internal/device_connect/protocol/scrcpy.go @@ -0,0 +1,299 @@ +package protocol + +import ( + "encoding/binary" + "fmt" + "io" + "strings" +) + +// Scrcpy packet header size +const PacketHeaderSize = 12 + +// Packet flags +const ( + PacketFlagConfig = uint64(1) << 63 + PacketFlagKeyFrame = uint64(1) << 62 + PacketPTSMask = PacketFlagKeyFrame - 1 +) + +// Codec IDs +const ( + CodecIDH264 = uint32(0x68323634) // "h264" in ASCII + CodecIDH265 = uint32(0x68323635) // "h265" in ASCII + CodecIDAV1 = uint32(0x00617631) // "av1" in ASCII + CodecIDOPUS = uint32(0x6f707573) // "opus" in ASCII + CodecIDAAC = uint32(0x00616163) // "aac" in ASCII + CodecIDFLAC = uint32(0x666c6163) // "flac" in ASCII + CodecIDRAW = uint32(0x00726177) // "raw" in ASCII + CodecIDDisabled = uint32(0x80000000) // Audio/Video disabled +) + +// Video packet structure +type VideoPacket struct { + PTS uint64 + PacketSize uint32 + Data []byte + IsKeyFrame bool + IsConfig bool +} + +// Audio packet structure +type AudioPacket struct { + PTS uint64 + PacketSize uint32 + Data []byte +} + +// Device metadata +type DeviceMeta struct { + DeviceName string + Width uint32 + Height uint32 +} + +// Control message types +const ( + ControlMsgTypeInjectKeycode = 0 + ControlMsgTypeInjectText = 1 + ControlMsgTypeInjectTouchEvent = 2 + ControlMsgTypeInjectScrollEvent = 3 + ControlMsgTypeBackOrScreenOn = 4 + ControlMsgTypeExpandNotification = 5 + ControlMsgTypeExpandSettings = 6 + ControlMsgTypeCollapsePanels = 7 + ControlMsgTypeGetClipboard = 8 + ControlMsgTypeSetClipboard = 9 + ControlMsgTypeSetDisplayPower = 10 + ControlMsgTypeRotateDevice = 11 + ControlMsgTypeUhidCreate = 12 + ControlMsgTypeUhidInput = 13 + ControlMsgTypeUhidDestroy = 14 + ControlMsgTypeOpenHardKeyboard = 15 + ControlMsgTypeStartApp = 16 + ControlMsgTypeResetVideo = 17 +) + +// Control message structure +type ControlMessage struct { + Type uint8 + Sequence uint64 + Data []byte +} + +// ScrcpyTouchEvent represents internal touch event for scrcpy protocol +type ScrcpyTouchEvent struct { + Action uint8 + PointerID uint64 + Position Position + Pressure float32 + Buttons uint32 +} + +// ScrcpyKeyEvent represents internal key event for scrcpy protocol +type ScrcpyKeyEvent struct { + Action uint8 + Keycode uint32 + Repeat uint32 + MetaState uint32 +} + +// Position structure +type Position struct { + X float32 + Y float32 +} + +// Read video packet from stream +func ReadVideoPacket(reader io.Reader) (*VideoPacket, error) { + header := make([]byte, PacketHeaderSize) + n, err := io.ReadFull(reader, header) + if err != nil { + if n == 0 && err == io.EOF { + return nil, io.EOF + } + return nil, fmt.Errorf("failed to read header: %w", err) + } + + ptsFlags := binary.BigEndian.Uint64(header[0:8]) + packetSize := binary.BigEndian.Uint32(header[8:12]) + + if packetSize == 0 { + return nil, fmt.Errorf("invalid packet size: 0") + } + + // Sanity check packet size + if packetSize > 10*1024*1024 { // 10MB max + return nil, fmt.Errorf("packet size too large: %d", packetSize) + } + + data := make([]byte, packetSize) + if _, err := io.ReadFull(reader, data); err != nil { + return nil, fmt.Errorf("failed to read packet data: %w", err) + } + + return &VideoPacket{ + PTS: ptsFlags & PacketPTSMask, + PacketSize: packetSize, + Data: data, + IsKeyFrame: (ptsFlags & PacketFlagKeyFrame) != 0, + IsConfig: (ptsFlags & PacketFlagConfig) != 0, + }, nil +} + +// Read audio packet from stream +func ReadAudioPacket(reader io.Reader) (*AudioPacket, error) { + header := make([]byte, PacketHeaderSize) + n, err := io.ReadFull(reader, header) + if err != nil { + if n == 0 && err == io.EOF { + return nil, io.EOF + } + return nil, fmt.Errorf("failed to read header: %w", err) + } + + ptsFlags := binary.BigEndian.Uint64(header[0:8]) + packetSize := binary.BigEndian.Uint32(header[8:12]) + + if packetSize == 0 { + return nil, fmt.Errorf("invalid packet size: 0") + } + + // Sanity check packet size + if packetSize > 1*1024*1024 { // 1MB max for audio + return nil, fmt.Errorf("packet size too large: %d", packetSize) + } + + data := make([]byte, packetSize) + if _, err := io.ReadFull(reader, data); err != nil { + return nil, fmt.Errorf("failed to read packet data: %w", err) + } + + return &AudioPacket{ + PTS: ptsFlags & PacketPTSMask, + PacketSize: packetSize, + Data: data, + }, nil +} + +// Read device metadata (following ACTUAL scrcpy protocol - only device name!) +func ReadDeviceMeta(reader io.Reader) (*DeviceMeta, error) { + // According to REAL scrcpy source, device_read_info only reads device name (64 bytes) + const deviceNameFieldLength = 64 + nameBytes := make([]byte, deviceNameFieldLength) + if _, err := io.ReadFull(reader, nameBytes); err != nil { + return nil, err + } + + // Remove null bytes and get device name + deviceName := strings.TrimRight(string(nameBytes), "\x00") + + return &DeviceMeta{ + DeviceName: deviceName, + Width: 0, // Will be determined from video stream + Height: 0, // Will be determined from video stream + }, nil +} + +// Serialize control message +func SerializeControlMessage(msg *ControlMessage) []byte { + // Scrcpy control message format: + // - type (1 byte) + // - payload (varies by type) + buf := make([]byte, 0, 1024) + buf = append(buf, msg.Type) + buf = append(buf, msg.Data...) + return buf +} + +// SerializeTouchEvent converts API TouchEvent to scrcpy format +func SerializeTouchEvent(event *TouchEvent, screenWidth, screenHeight uint16) []byte { + buf := make([]byte, 0, 32) + + // Convert action string to byte + var action uint8 + switch event.Action { + case "down": + action = 0 + case "up": + action = 1 + case "move": + action = 2 + default: + action = 2 + } + buf = append(buf, action) + + // Pointer ID (8 bytes) + ptrBytes := make([]byte, 8) + binary.BigEndian.PutUint64(ptrBytes, uint64(event.PointerID)) + buf = append(buf, ptrBytes...) + + // Position structure: + // - x (4 bytes) + // - y (4 bytes) + // - screenWidth (2 bytes) + // - screenHeight (2 bytes) + posBytes := make([]byte, 12) + binary.BigEndian.PutUint32(posBytes[0:4], uint32(event.X * float64(screenWidth))) + binary.BigEndian.PutUint32(posBytes[4:8], uint32(event.Y * float64(screenHeight))) + // Screen dimensions - use actual device screen size + binary.BigEndian.PutUint16(posBytes[8:10], screenWidth) + binary.BigEndian.PutUint16(posBytes[10:12], screenHeight) + buf = append(buf, posBytes...) + + // Pressure (16-bit, 0xFFFF = 1.0) + pressureBytes := make([]byte, 2) + binary.BigEndian.PutUint16(pressureBytes, uint16(event.Pressure*0xFFFF)) + buf = append(buf, pressureBytes...) + + // Action button (32-bit) - which button triggered the action + actionButtonBytes := make([]byte, 4) + if action == 0 || action == 1 { // DOWN or UP + binary.BigEndian.PutUint32(actionButtonBytes, 1) // Primary button + } else { + binary.BigEndian.PutUint32(actionButtonBytes, 0) // No button for MOVE + } + buf = append(buf, actionButtonBytes...) + + // Buttons (32-bit) - current button state + buttonBytes := make([]byte, 4) + binary.BigEndian.PutUint32(buttonBytes, 1) // Primary button + buf = append(buf, buttonBytes...) + + return buf +} + +// SerializeKeyEvent converts API KeyEvent to scrcpy format +func SerializeKeyEvent(event *KeyEvent) []byte { + buf := make([]byte, 0, 16) + + // Convert action string to byte + var action uint8 + switch event.Action { + case "down": + action = 0 + case "up": + action = 1 + default: + action = 1 + } + buf = append(buf, action) + + // Keycode + keyBytes := make([]byte, 4) + binary.BigEndian.PutUint32(keyBytes, uint32(event.Keycode)) + buf = append(buf, keyBytes...) + + // Repeat + repeatBytes := make([]byte, 4) + binary.BigEndian.PutUint32(repeatBytes, uint32(event.Repeat)) + buf = append(buf, repeatBytes...) + + // Meta state + metaBytes := make([]byte, 4) + binary.BigEndian.PutUint32(metaBytes, uint32(event.MetaState)) + buf = append(buf, metaBytes...) + + return buf +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/server.go b/packages/cli/internal/device_connect/server.go new file mode 100644 index 00000000..b90008a4 --- /dev/null +++ b/packages/cli/internal/device_connect/server.go @@ -0,0 +1,57 @@ +package device_connect + +import ( + "fmt" + "log" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/api" +) + +// Server is the main device connect server +type Server struct { + apiServer *api.Server + port int +} + +// NewServer creates a new device connect server +func NewServer(port int) *Server { + return &Server{ + port: port, + apiServer: api.NewServer(port), + } +} + +// Start starts the device connect server +func (s *Server) Start() error { + log.Printf("Starting device connect server on port %d", s.port) + + // Start the API server + if err := s.apiServer.Start(); err != nil { + return fmt.Errorf("failed to start API server: %w", err) + } + + log.Printf("Device connect server started successfully on http://localhost:%d", s.port) + return nil +} + +// Stop stops the device connect server +func (s *Server) Stop() error { + log.Println("Stopping device connect server...") + + if s.apiServer != nil { + if err := s.apiServer.Stop(); err != nil { + return fmt.Errorf("failed to stop API server: %w", err) + } + } + + log.Println("Device connect server stopped") + return nil +} + +// IsRunning returns whether the server is running +func (s *Server) IsRunning() bool { + if s.apiServer != nil { + return s.apiServer.IsRunning() + } + return false +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/stream/audio.go b/packages/cli/internal/device_connect/stream/audio.go new file mode 100644 index 00000000..81d1883f --- /dev/null +++ b/packages/cli/internal/device_connect/stream/audio.go @@ -0,0 +1,99 @@ +package stream + +import ( + "encoding/binary" + "io" + "log" + "time" + + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media" +) + +// AudioHandler handles audio stream processing +type AudioHandler struct { + track *webrtc.TrackLocalStaticSample + sampleRate uint32 + channels uint16 +} + +// NewAudioHandler creates a new audio stream handler +func NewAudioHandler(track *webrtc.TrackLocalStaticSample) *AudioHandler { + return &AudioHandler{ + track: track, + sampleRate: 48000, // Default Opus sample rate + channels: 2, // Stereo + } +} + +// HandleStream processes audio stream from device +func (vh *AudioHandler) HandleStream(reader io.Reader) error { + // Read audio metadata + metaBuf := make([]byte, 4) // codecId + if _, err := io.ReadFull(reader, metaBuf); err != nil { + return err + } + + codecID := binary.BigEndian.Uint32(metaBuf) + log.Printf("Audio stream started - Codec: %d", codecID) + + // Start streaming audio packets + return vh.streamPackets(reader) +} + +// streamPackets reads and processes audio packets +func (vh *AudioHandler) streamPackets(reader io.Reader) error { + for { + // Read packet header (8 bytes: PTS) + header := make([]byte, 8) + if _, err := io.ReadFull(reader, header); err != nil { + if err == io.EOF { + log.Println("Audio stream ended") + return nil + } + return err + } + + pts := binary.BigEndian.Uint64(header) + + // Read packet size (4 bytes) + sizeBuf := make([]byte, 4) + if _, err := io.ReadFull(reader, sizeBuf); err != nil { + return err + } + + packetSize := binary.BigEndian.Uint32(sizeBuf) + if packetSize > 1024*1024 { // 1MB max + log.Printf("Warning: Large audio packet: %d bytes", packetSize) + continue + } + + // Read packet data + packetData := make([]byte, packetSize) + if _, err := io.ReadFull(reader, packetData); err != nil { + return err + } + + // Send audio packet + if err := vh.sendAudioPacket(packetData, pts); err != nil { + log.Printf("Error sending audio packet: %v", err) + } + } +} + +// sendAudioPacket sends audio data via WebRTC +func (vh *AudioHandler) sendAudioPacket(data []byte, pts uint64) error { + if vh.track == nil { + return nil + } + + // Calculate duration based on Opus frame size (20ms frames typical) + duration := 20 * time.Millisecond + + sample := media.Sample{ + Data: data, + Duration: duration, + } + + return vh.track.WriteSample(sample) +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/stream/control.go b/packages/cli/internal/device_connect/stream/control.go new file mode 100644 index 00000000..6a4de231 --- /dev/null +++ b/packages/cli/internal/device_connect/stream/control.go @@ -0,0 +1,502 @@ +package stream + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" + "github.com/pion/webrtc/v4" +) + +// ControlHandler handles control stream and messages +type ControlHandler struct { + conn net.Conn + dataChannel *webrtc.DataChannel + screenWidth int + screenHeight int +} + +// NewControlHandler creates a new control stream handler +func NewControlHandler(conn net.Conn, dataChannel *webrtc.DataChannel, screenWidth, screenHeight int) *ControlHandler { + log.Printf("NewControlHandler: creating with conn=%v, dataChannel=%v, screen=%dx%d", + conn != nil, dataChannel != nil, screenWidth, screenHeight) + return &ControlHandler{ + conn: conn, + dataChannel: dataChannel, + screenWidth: screenWidth, + screenHeight: screenHeight, + } +} + +// HandleIncomingMessages handles control messages from WebRTC +func (ch *ControlHandler) HandleIncomingMessages() { + log.Printf("HandleIncomingMessages called") + if ch.dataChannel == nil { + log.Printf("DataChannel is nil, cannot set up message handling") + return + } + + log.Printf("Setting up DataChannel message handling, DataChannel state: %s", ch.dataChannel.ReadyState()) + log.Printf("About to set OnMessage handler for DataChannel") + + ch.dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) { + log.Printf("DataChannel message received, data length: %d", len(msg.Data)) + log.Printf("DataChannel message data: %s", string(msg.Data)) + + // Parse control message + var message map[string]interface{} + if err := json.Unmarshal(msg.Data, &message); err != nil { + log.Printf("Failed to parse control message: %v", err) + return + } + + // Handle both string and numeric type fields + var msgType string + switch v := message["type"].(type) { + case string: + msgType = v + case float64: + // Convert numeric type to string for clipboard messages + switch int(v) { + case 8: + msgType = "clipboard_get" + case 9: + msgType = "clipboard_set" + default: + log.Printf("Unknown numeric control message type: %d", int(v)) + return + } + default: + log.Printf("Control message missing or invalid type field: %v", message) + return + } + + log.Printf("Received control message: type=%s", msgType) + + switch msgType { + case "ping": + ch.handlePingMessage(message) + case "key": + ch.handleKeyEvent(message) + case "touch": + ch.handleTouchEvent(message) + case "scroll": + ch.handleScrollEvent(message) + case "reset_video": + ch.handleResetVideo(message) + case "clipboard_get": + ch.handleClipboardGet(message) + case "clipboard_set": + ch.handleClipboardSet(message) + default: + log.Printf("Unknown control message type: %s", msgType) + } + }) + + log.Printf("OnMessage handler set successfully for DataChannel") +} + +// handlePingMessage handles ping/pong messages for connection health +func (ch *ControlHandler) handlePingMessage(message map[string]interface{}) { + if id, hasId := message["id"].(string); hasId { + pongResponse := map[string]interface{}{ + "type": "pong", + "id": id, + "timestamp": time.Now().UnixNano() / int64(time.Millisecond), + } + + if pongData, err := json.Marshal(pongResponse); err == nil { + if ch.dataChannel != nil && ch.dataChannel.ReadyState() == webrtc.DataChannelStateOpen { + if err := ch.dataChannel.Send(pongData); err != nil { + log.Printf("Failed to send pong response: %v", err) + } + } + } + } +} + +// handleKeyEvent processes keyboard events +func (ch *ControlHandler) handleKeyEvent(message map[string]interface{}) { + action, _ := message["action"].(string) + keycode, _ := message["keycode"].(float64) + metaState, _ := message["metaState"].(float64) + repeat, _ := message["repeat"].(float64) + + log.Printf("Key event: action=%s, keycode=%d, meta=%d", action, int(keycode), int(metaState)) + + // Send to device via control connection + if ch.conn != nil { + ch.SendKeyEventToDevice(action, int(keycode), int(metaState), int(repeat)) + } +} + +// handleTouchEvent processes touch/mouse events +func (ch *ControlHandler) handleTouchEvent(message map[string]interface{}) { + action, _ := message["action"].(string) + x, _ := message["x"].(float64) + y, _ := message["y"].(float64) + pressure, _ := message["pressure"].(float64) + pointerId, _ := message["pointerId"].(float64) + + log.Printf("Touch event: action=%s, pos=(%.2f, %.2f), pressure=%.2f", action, x, y, pressure) + + // Send to device via control connection + if ch.conn != nil { + log.Printf("Sending touch event to device: action=%s, x=%.2f, y=%.2f", action, x, y) + ch.SendTouchEventToDevice(action, x, y, pressure, int(pointerId)) + } else { + log.Printf("Control connection is nil, cannot send touch event") + } +} + +// handleScrollEvent processes scroll events +func (ch *ControlHandler) handleScrollEvent(message map[string]interface{}) { + x, _ := message["x"].(float64) + y, _ := message["y"].(float64) + hScroll, _ := message["hScroll"].(float64) + vScroll, _ := message["vScroll"].(float64) + + log.Printf("Scroll event: pos=(%.2f, %.2f), scroll=(%.2f, %.2f)", x, y, hScroll, vScroll) + + // Send to device via control connection + if ch.conn != nil { + log.Printf("Sending scroll event to device: x=%.2f, y=%.2f, hScroll=%.2f, vScroll=%.2f", x, y, hScroll, vScroll) + ch.SendScrollEventToDevice(x, y, hScroll, vScroll) + } else { + log.Printf("Control connection is nil, cannot send scroll event - this is expected during initial connection setup") + // This is expected during initial connection setup, the connection will be updated later + // We could queue the event here if needed, but for now just log it + } +} + +// handleResetVideo handles video reset requests (keyframe) +func (ch *ControlHandler) handleResetVideo(message map[string]interface{}) { + log.Println("Reset video requested (keyframe)") + // This would trigger a keyframe request +} + +// handleClipboardGet handles clipboard get requests +func (ch *ControlHandler) handleClipboardGet(message map[string]interface{}) { + log.Println("Clipboard get requested") + // TODO: Implement clipboard get functionality + // This would get clipboard content from Android device and send it back +} + +// handleClipboardSet handles clipboard set requests +func (ch *ControlHandler) handleClipboardSet(message map[string]interface{}) { + log.Println("Clipboard set requested") + + // Check if this is a JSON format message (new format) or binary format (old format) + if textInterface, ok := message["text"]; ok { + // JSON format: {"type": "clipboard_set", "text": "你好", "paste": true} + text, ok := textInterface.(string) + if !ok { + log.Printf("Clipboard set message text field is not a string") + return + } + + paste := false + if pasteInterface, ok := message["paste"]; ok { + if pasteBool, ok := pasteInterface.(bool); ok { + paste = pasteBool + } + } + + log.Printf("Clipboard set (JSON format): text='%s', paste=%v", text, paste) + + // Send clipboard data to Android device using scrcpy protocol + ch.sendClipboardToDevice(text, paste) + return + } + + // Binary format: extract data from message + dataInterface, ok := message["data"] + if !ok { + log.Printf("Clipboard set message missing both text and data fields") + return + } + + // Convert data to byte array - handle both array and map formats + var data []byte + + // Try array format first (new format) + if dataArray, ok := dataInterface.([]interface{}); ok { + for _, val := range dataArray { + if byteVal, ok := val.(float64); ok { + data = append(data, byte(byteVal)) + } + } + } else if dataMap, ok := dataInterface.(map[string]interface{}); ok { + // Fallback to map format (old format) + for i := 0; i < len(dataMap); i++ { + if val, exists := dataMap[fmt.Sprintf("%d", i)]; exists { + if byteVal, ok := val.(float64); ok { + data = append(data, byte(byteVal)) + } + } + } + } else { + log.Printf("Clipboard set message data is not in expected format (array or map)") + return + } + + if len(data) < 13 { + log.Printf("Clipboard set message data too short: %d bytes", len(data)) + return + } + + // Parse clipboard data according to scrcpy protocol + // [Sequence (8 bytes)][Paste flag (1 byte)][Text length (4 bytes, big endian)][Text data] + // Note: Type is handled separately, not in data + sequence := int64(data[0])<<56 | int64(data[1])<<48 | int64(data[2])<<40 | int64(data[3])<<32 | + int64(data[4])<<24 | int64(data[5])<<16 | int64(data[6])<<8 | int64(data[7]) + pasteFlag := data[8] + textLength := int(data[9])<<24 | int(data[10])<<16 | int(data[11])<<8 | int(data[12]) + + if len(data) < 13+textLength { + log.Printf("Clipboard set message data incomplete: expected %d bytes, got %d", 13+textLength, len(data)) + return + } + + text := string(data[13 : 13+textLength]) + log.Printf("Clipboard set (binary format): sequence=%d, paste=%d, text='%s'", sequence, pasteFlag, text) + + // Send clipboard data to Android device using scrcpy protocol + ch.sendClipboardToDevice(text, pasteFlag == 1) +} + +// sendClipboardToDevice sends clipboard data to Android device +func (ch *ControlHandler) sendClipboardToDevice(text string, paste bool) { + if ch.conn == nil { + log.Printf("No connection available for clipboard operation") + return + } + + // Create clipboard control message according to scrcpy protocol + // Format: [Sequence (8 bytes)][Paste flag (1 byte)][Text length (4 bytes)][Text data] + // Note: Type is handled by ControlMessage.Type field, not in buffer + textBytes := []byte(text) + textLength := len(textBytes) + buffer := make([]byte, 8+1+4+textLength) + offset := 0 + + // Sequence (8 bytes, big endian) - use 0 for now + buffer[offset] = 0 + buffer[offset+1] = 0 + buffer[offset+2] = 0 + buffer[offset+3] = 0 + buffer[offset+4] = 0 + buffer[offset+5] = 0 + buffer[offset+6] = 0 + buffer[offset+7] = 0 + offset += 8 + + // Paste flag (1 byte) - 0 for just set, 1 for set and paste + if paste { + buffer[offset] = 1 + } else { + buffer[offset] = 0 + } + offset++ + + // Text length (4 bytes, big endian) - use actual text length + buffer[offset] = byte(textLength >> 24) + buffer[offset+1] = byte(textLength >> 16) + buffer[offset+2] = byte(textLength >> 8) + buffer[offset+3] = byte(textLength) + offset += 4 + + // Text data + copy(buffer[offset:], textBytes) + + // Debug: verify buffer size matches expected size + expectedSize := 8 + 1 + 4 + textLength + if len(buffer) != expectedSize { + log.Printf("ERROR: Buffer size mismatch! Expected: %d, Actual: %d", expectedSize, len(buffer)) + } + + // Create control message + controlMsg := &device.ControlMessage{ + Type: protocol.ControlMsgTypeSetClipboard, + Sequence: 0, + Data: buffer, + } + + // Debug: log the buffer content + log.Printf("Clipboard buffer length: %d", len(buffer)) + if len(buffer) >= 20 { + log.Printf("Clipboard buffer first 20 bytes: %v", buffer[:20]) + log.Printf("Clipboard buffer last 20 bytes: %v", buffer[len(buffer)-20:]) + } else { + log.Printf("Clipboard buffer content: %v", buffer) + } + + // Send to device + ch.sendControlMessage(controlMsg) + log.Printf("Clipboard data sent to device: text='%s', paste=%v", text, paste) +} + +// SendKeyEventToDevice sends key event to Android device using protocol package +func (ch *ControlHandler) SendKeyEventToDevice(action string, keycode, metaState, repeat int) { + if ch.conn == nil { + return + } + + keyEvent := &protocol.KeyEvent{ + Action: action, + Keycode: keycode, + Repeat: repeat, + MetaState: metaState, + } + + controlMsg := &device.ControlMessage{ + Type: protocol.ControlMsgTypeInjectKeycode, + Sequence: 0, + Data: protocol.EncodeKeyEvent(*keyEvent), + } + + ch.sendControlMessage(controlMsg) +} + +// SendTouchEventToDevice sends touch event to Android device using protocol package +func (ch *ControlHandler) SendTouchEventToDevice(action string, x, y, pressure float64, pointerId int) { + if ch.conn == nil { + return + } + + touchEvent := &protocol.TouchEvent{ + Action: action, + X: x, + Y: y, + PointerID: pointerId, + Pressure: pressure, + } + + controlMsg := &device.ControlMessage{ + Type: protocol.ControlMsgTypeInjectTouchEvent, + Sequence: 0, + Data: protocol.EncodeTouchEvent(*touchEvent, ch.screenWidth, ch.screenHeight), + } + + ch.sendControlMessage(controlMsg) +} + +// SendScrollEventToDevice sends scroll event to Android device using protocol package +func (ch *ControlHandler) SendScrollEventToDevice(x, y, hScroll, vScroll float64) { + if ch.conn == nil { + log.Printf("SendScrollEventToDevice: control connection is nil") + return + } + + scrollEvent := &protocol.ScrollEvent{ + X: x, + Y: y, + HScroll: hScroll, + VScroll: vScroll, + } + + log.Printf("SendScrollEventToDevice: creating scroll event with screen=%dx%d", ch.screenWidth, ch.screenHeight) + log.Printf("SendScrollEventToDevice: scroll event data: x=%.2f, y=%.2f, hScroll=%.2f, vScroll=%.2f", x, y, hScroll, vScroll) + + controlMsg := &device.ControlMessage{ + Type: protocol.ControlMsgTypeInjectScrollEvent, + Sequence: 0, + Data: protocol.EncodeScrollEvent(*scrollEvent, ch.screenWidth, ch.screenHeight), + } + + log.Printf("SendScrollEventToDevice: encoded data length=%d", len(controlMsg.Data)) + ch.sendControlMessage(controlMsg) +} + +// sendControlMessage sends a control message to the device +func (ch *ControlHandler) sendControlMessage(msg *device.ControlMessage) { + if ch.conn == nil { + log.Printf("Control connection is nil, cannot send message type %d", msg.Type) + return + } + + // Serialize control message according to scrcpy protocol + // Format: [message_type][data] + buf := make([]byte, 1+len(msg.Data)) + buf[0] = byte(msg.Type) + copy(buf[1:], msg.Data) + + log.Printf("Sending control message to device: type=%d, data_len=%d", msg.Type, len(msg.Data)) + + // Debug: log the actual data being sent to scrcpy server for clipboard messages + if msg.Type == protocol.ControlMsgTypeSetClipboard { + log.Printf("Clipboard message - Total length: %d", len(buf)) + if len(buf) >= 20 { + log.Printf("First 20 bytes: %v", buf[:20]) + log.Printf("Last 20 bytes: %v", buf[len(buf)-20:]) + } else { + log.Printf("All data: %v", buf) + } + } + + if _, err := ch.conn.Write(buf); err != nil { + log.Printf("Failed to send control message: %v", err) + // Mark connection as invalid to prevent further attempts + ch.conn = nil + } else { + log.Printf("Control message sent successfully") + } +} + +// SendKeyFrameRequest sends a keyframe request to the device +func (ch *ControlHandler) SendKeyFrameRequest() { + keyframeRequest := &device.ControlMessage{ + Type: protocol.ControlMsgTypeResetVideo, + Sequence: 0, + Data: []byte{}, + } + ch.sendControlMessage(keyframeRequest) +} + +// UpdateScreenDimensions updates the screen dimensions for coordinate conversion +func (ch *ControlHandler) UpdateScreenDimensions(width, height int) { + ch.screenWidth = width + ch.screenHeight = height + log.Printf("Updated screen dimensions: %dx%d", width, height) +} + +// UpdateConnection updates the control connection +func (ch *ControlHandler) UpdateConnection(conn net.Conn) { + ch.conn = conn + log.Printf("Updated control connection") +} + +// UpdateDataChannel updates the DataChannel +func (ch *ControlHandler) UpdateDataChannel(dataChannel *webrtc.DataChannel) { + ch.dataChannel = dataChannel + log.Printf("Updated DataChannel") +} + +// HandleOutgoingMessages handles messages from device to WebRTC +func (ch *ControlHandler) HandleOutgoingMessages() { + if ch.conn == nil { + return + } + + // Read clipboard or other events from device + buffer := make([]byte, 4096) + for { + n, err := ch.conn.Read(buffer) + if err != nil { + if err != io.EOF { + log.Printf("Control stream read error: %v", err) + } + break + } + + if n > 0 { + // Process control response from device + log.Printf("Received control response: %d bytes", n) + } + } +} diff --git a/packages/cli/internal/device_connect/stream/video.go b/packages/cli/internal/device_connect/stream/video.go new file mode 100644 index 00000000..c4b4ab8e --- /dev/null +++ b/packages/cli/internal/device_connect/stream/video.go @@ -0,0 +1,173 @@ +package stream + +import ( + "encoding/binary" + "io" + "log" + "time" + + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media" +) + +// VideoHandler handles video stream processing +type VideoHandler struct { + track *webrtc.TrackLocalStaticSample + width int + height int + lastKeyframe time.Time +} + +// NewVideoHandler creates a new video stream handler +func NewVideoHandler(track *webrtc.TrackLocalStaticSample) *VideoHandler { + return &VideoHandler{ + track: track, + lastKeyframe: time.Now(), + } +} + +// HandleStream processes video stream from device +func (vh *VideoHandler) HandleStream(reader io.Reader) error { + // Read video metadata + metaBuf := make([]byte, 12) // codecId(4) + width(4) + height(4) + if _, err := io.ReadFull(reader, metaBuf); err != nil { + return err + } + + codecID := binary.BigEndian.Uint32(metaBuf[0:4]) + vh.width = int(binary.BigEndian.Uint32(metaBuf[4:8])) + vh.height = int(binary.BigEndian.Uint32(metaBuf[8:12])) + + log.Printf("Video stream started - Codec: %d, Resolution: %dx%d", codecID, vh.width, vh.height) + + // Start streaming video packets + return vh.streamPackets(reader) +} + +// streamPackets reads and processes video packets +func (vh *VideoHandler) streamPackets(reader io.Reader) error { + const maxPacketSize = 1024 * 1024 // 1MB max packet size + sequenceNumber := uint16(0) + + for { + // Read packet header (8 bytes: PTS high + PTS low) + header := make([]byte, 8) + if _, err := io.ReadFull(reader, header); err != nil { + if err == io.EOF { + log.Println("Video stream ended") + return nil + } + return err + } + + pts := binary.BigEndian.Uint64(header) + + // Read packet size (4 bytes) + sizeBuf := make([]byte, 4) + if _, err := io.ReadFull(reader, sizeBuf); err != nil { + return err + } + + packetSize := binary.BigEndian.Uint32(sizeBuf) + if packetSize > maxPacketSize { + log.Printf("Warning: Large video packet: %d bytes", packetSize) + continue + } + + // Read packet data + packetData := make([]byte, packetSize) + if _, err := io.ReadFull(reader, packetData); err != nil { + return err + } + + // Check for keyframe (H.264 NAL unit type) + if len(packetData) > 4 { + nalType := packetData[4] & 0x1F + if nalType == 5 || nalType == 7 || nalType == 8 { // IDR, SPS, PPS + vh.lastKeyframe = time.Now() + } + } + + // Fragment and send via RTP + if err := vh.sendVideoPacket(packetData, pts, &sequenceNumber); err != nil { + log.Printf("Error sending video packet: %v", err) + } + } +} + +// sendVideoPacket sends video data as RTP packets +func (vh *VideoHandler) sendVideoPacket(data []byte, pts uint64, seqNum *uint16) error { + if vh.track == nil { + return nil + } + + const maxRTPPayloadSize = 1200 // Leave room for RTP headers + + // Fragment large NAL units + if len(data) <= maxRTPPayloadSize { + // Single NAL unit - write directly as sample + *seqNum++ + + sample := media.Sample{ + Data: data, + Duration: time.Millisecond * 33, // ~30 fps + } + return vh.track.WriteSample(sample) + } + + // Fragment into FU-A packets (H.264 fragmentation) + nalHeader := data[0] + data = data[1:] // Skip NAL header + + for len(data) > 0 { + payloadSize := len(data) + if payloadSize > maxRTPPayloadSize-2 { // -2 for FU header + payloadSize = maxRTPPayloadSize - 2 + } + + // Build FU-A header + fuHeader := make([]byte, 2) + fuHeader[0] = (nalHeader & 0xE0) | 28 // FU-A type + fuHeader[1] = nalHeader & 0x1F // Original NAL type + + if len(data) == payloadSize { + fuHeader[1] |= 0x40 // End bit + } + if len(data) == len(data) { // First fragment (when data is still complete) + fuHeader[1] |= 0x80 // Start bit + } + + payload := append(fuHeader, data[:payloadSize]...) + marker := len(data) == payloadSize // Last fragment + + // No longer need RTP packet for WriteSample + *seqNum++ + _ = marker // Mark as used + + // Collect all fragments and write complete NAL unit + if marker { + sample := media.Sample{ + Data: payload, + Duration: time.Millisecond * 33, + } + if err := vh.track.WriteSample(sample); err != nil { + return err + } + } + + data = data[payloadSize:] + } + + return nil +} + +// GetDimensions returns the video dimensions +func (vh *VideoHandler) GetDimensions() (int, int) { + return vh.width, vh.height +} + +// RequestKeyframe requests a keyframe from the encoder +func (vh *VideoHandler) RequestKeyframe() { + // This would send a request to the device for a keyframe + log.Println("Keyframe requested") +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/webrtc/bridge.go b/packages/cli/internal/device_connect/webrtc/bridge.go new file mode 100644 index 00000000..6e007c19 --- /dev/null +++ b/packages/cli/internal/device_connect/webrtc/bridge.go @@ -0,0 +1,830 @@ +package webrtc + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "log" + "net" + "sync" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/stream" + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media" +) + +// Bridge bridges WebRTC connection with Android device streams +type Bridge struct { + DeviceSerial string + WebRTCConn *webrtc.PeerConnection + DataChannel *webrtc.DataChannel + WSConnection interface{} // WebSocket connection for sending info + + // Tracks + VideoTrack *webrtc.TrackLocalStaticSample + AudioTrack *webrtc.TrackLocalStaticSample + + // Device connections + scrcpyConn *device.ScrcpyConnection + + // Stream connections + videoConn net.Conn + audioConn net.Conn + controlConn net.Conn + + // Control handler + controlHandler *stream.ControlHandler + + // Video dimensions + VideoWidth int + VideoHeight int + + // Control flow + controlReady chan struct{} + controlMutex sync.Mutex // Protect controlReady channel operations + Context context.Context + cancel context.CancelFunc + + // Synchronization + mu sync.Mutex + closed bool +} + +// NewBridge creates a new WebRTC bridge for a device +func NewBridge(deviceSerial string, adbPath string) (*Bridge, error) { + log.Printf("NewBridge called for device: %s", deviceSerial) + ctx, cancel := context.WithCancel(context.Background()) + + // Create WebRTC peer connection + pc, err := CreatePeerConnection() + if err != nil { + cancel() + return nil, fmt.Errorf("failed to create peer connection: %w", err) + } + + // Create bridge + bridge := &Bridge{ + DeviceSerial: deviceSerial, + WebRTCConn: pc, + Context: ctx, + cancel: cancel, + controlReady: make(chan struct{}), + VideoWidth: 720, // Default dimensions + VideoHeight: 1280, + } + + // Set up data channel receiver (frontend will create the data channel) + pc.OnDataChannel(func(dc *webrtc.DataChannel) { + log.Printf("Received DataChannel: %s", dc.Label()) + if dc.Label() == "control" { + bridge.DataChannel = dc + log.Printf("Control DataChannel received and assigned") + // Set up control handler when DataChannel is received + if bridge.controlHandler != nil { + log.Printf("Setting up control handler with received DataChannel") + bridge.controlHandler.UpdateDataChannel(dc) + bridge.controlHandler.HandleIncomingMessages() + } + } + }) + + // Set up data channel handlers + bridge.setupDataChannelHandlers() + + // Pre-create control handler with nil connection and nil DataChannel (will be updated when DataChannel is received) + log.Printf("Creating ControlHandler with nil DataChannel (will be updated when received)") + bridge.controlHandler = stream.NewControlHandler(nil, nil, 1080, 1920) + log.Printf("ControlHandler created successfully") + // HandleIncomingMessages will be called when DataChannel is received + + // Pre-create video and audio tracks for WebRTC negotiation + // Default to H264 video track + videoTrack, err := AddVideoTrack(pc, "h264") + if err != nil { + pc.Close() + cancel() + return nil, fmt.Errorf("failed to add video track: %w", err) + } + bridge.VideoTrack = videoTrack + + // Add audio track + audioTrack, err := AddAudioTrack(pc, "opus") + if err != nil { + pc.Close() + cancel() + return nil, fmt.Errorf("failed to add audio track: %w", err) + } + bridge.AudioTrack = audioTrack + + return bridge, nil +} + +// Start starts the bridge connection to device +func (b *Bridge) Start() error { + // Generate SCID for this connection - use a valid port range (27183-37183) + // This gives us 10000 possible ports for concurrent connections + scid := uint32(27183 + (time.Now().UnixNano() % 10000)) + + // Create scrcpy connection + b.scrcpyConn = device.NewScrcpyConnection(b.DeviceSerial, scid) + + // Connect to scrcpy server + conn, err := b.scrcpyConn.Connect() + if err != nil { + return fmt.Errorf("failed to connect to scrcpy: %w", err) + } + + // Store the connection + b.videoConn = conn + + // Start media streaming from the first connection + go b.startMediaStreaming(conn) + + // Accept additional stream connections (audio, control) + if b.scrcpyConn.Listener != nil { + go b.acceptStreamConnections(b.scrcpyConn.Listener) + } + + return nil +} + +// acceptStreamConnections accepts incoming stream connections from device +func (b *Bridge) acceptStreamConnections(listener net.Listener) { + connectionCount := 1 // Start from 1 since video connection is already handled + + for { + select { + case <-b.Context.Done(): + return + default: + conn, err := listener.Accept() + if err != nil { + select { + case <-b.Context.Done(): + return + default: + log.Printf("Failed to accept stream connection: %v", err) + continue + } + } + + connectionCount++ + log.Printf("Accepted stream connection #%d", connectionCount) + + go b.handleStreamConnection(conn) + } + } +} + +// Codec IDs are imported from protocol package + +// handleStreamConnection handles an incoming stream connection +func (b *Bridge) handleStreamConnection(conn net.Conn) { + defer conn.Close() + + // Set timeout for codec ID reading + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + + // Read codec ID + codecIDBytes := make([]byte, 4) + n, err := io.ReadFull(conn, codecIDBytes) + if err != nil { + if err == io.EOF && n == 0 { + log.Println("Connection closed immediately, treating as control") + b.handleControlStream(conn) + return + } + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + log.Println("No codec ID received, treating as control") + b.handleControlStream(conn) + return + } + log.Printf("Failed to read codec ID: %v (read %d bytes)", err, n) + // If we read some bytes but not enough, it might be a partial codec ID + if n > 0 { + log.Printf("Partial codec ID data: %v", codecIDBytes[:n]) + } + conn.Close() + return + } + + conn.SetReadDeadline(time.Time{}) + codecID := binary.BigEndian.Uint32(codecIDBytes) + + switch codecID { + case protocol.CodecIDH264, protocol.CodecIDH265, protocol.CodecIDAV1: + log.Printf("Identified video stream with codec 0x%08x", codecID) + b.videoConn = conn + b.handleVideoStream(conn, codecID) + + case protocol.CodecIDOPUS, protocol.CodecIDAAC, protocol.CodecIDFLAC, protocol.CodecIDRAW: + log.Printf("Identified audio stream with codec 0x%08x", codecID) + b.audioConn = conn + b.handleAudioStream(conn, codecID) + + default: + if codecID == 0 { + log.Println("Stream explicitly disabled (codec ID = 0)") + } else if codecID == 1 { + log.Println("Stream configuration error (codec ID = 1)") + } else { + log.Printf("Unknown codec 0x%08x, treating as control stream", codecID) + b.handleControlStream(conn) + } + } +} + +// startMediaStreaming starts the main media streaming process +func (b *Bridge) startMediaStreaming(conn net.Conn) { + log.Println("Starting media streaming...") + + if conn == nil { + log.Println("Connection is nil!") + return + } + + // Read device metadata from the first connection + log.Println("Reading device metadata from first connection...") + + meta, err := device.ReadDeviceMeta(conn) + if err != nil { + log.Printf("Failed to read device metadata: %v", err) + meta = &device.DeviceMeta{ + DeviceName: "Unknown Device", + Width: 1080, + Height: 1920, + } + } + + log.Printf("Device: %s (%dx%d)", meta.DeviceName, meta.Width, meta.Height) + + // Start optimized streaming + go b.handleVideoStreamOptimized(conn) +} + +// handleVideoStreamOptimized processes the first video connection +func (b *Bridge) handleVideoStreamOptimized(conn net.Conn) { + log.Println("Processing optimized video stream") + + // Read codec ID from video stream + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + codecIDBytes := make([]byte, 4) + n, err := io.ReadFull(conn, codecIDBytes) + if err != nil { + log.Printf("Failed to read video codec ID: %v (read %d bytes)", err, n) + return + } + conn.SetReadDeadline(time.Time{}) + + codecID := binary.BigEndian.Uint32(codecIDBytes) + var codecName string + var isVideoCodec bool + + switch codecID { + case protocol.CodecIDH264: + codecName = "H264" + isVideoCodec = true + case protocol.CodecIDH265: + codecName = "H265" + isVideoCodec = true + case protocol.CodecIDAV1: + codecName = "AV1" + isVideoCodec = true + case 0x36340000: // Special case: "64" + nulls - treat as H264 + codecName = "H264 (fallback)" + isVideoCodec = true + codecID = protocol.CodecIDH264 // Use standard H264 ID + default: + codecName = fmt.Sprintf("UNKNOWN(0x%08x)", codecID) + isVideoCodec = false + } + log.Printf("Video codec: %s", codecName) + + // Verify it's a video codec + if isVideoCodec { + b.handleVideoStream(conn, codecID) + } else { + log.Printf("Expected video codec but got: %s", codecName) + conn.Close() + } +} + +// handleVideoStream processes video stream with codec +func (b *Bridge) handleVideoStream(conn net.Conn, codecID uint32) { + log.Printf("Starting video stream handler with codec ID: 0x%08x", codecID) + + // Read video dimensions + sizeData := make([]byte, 8) + if _, err := io.ReadFull(conn, sizeData); err != nil { + log.Printf("Failed to read video size: %v", err) + return + } + + width := binary.BigEndian.Uint32(sizeData[0:4]) + height := binary.BigEndian.Uint32(sizeData[4:8]) + b.VideoWidth = int(width) + b.VideoHeight = int(height) + log.Printf("Video stream dimensions: %dx%d", width, height) + + // Update control handler with actual screen dimensions + if b.controlHandler != nil { + b.controlHandler.UpdateScreenDimensions(int(width), int(height)) + } + + // Video track should already be created in NewBridge + if b.VideoTrack == nil { + log.Printf("Video track is nil! This should not happen.") + return + } + + // Request keyframe from scrcpy server + b.requestKeyFrame() + + // Start streaming video packets with ultra-low latency optimization + b.streamVideoOptimized(conn) +} + +// handleAudioStream processes audio stream +func (b *Bridge) handleAudioStream(conn net.Conn, codecID uint32) { + log.Printf("Starting audio stream handler with codec ID: 0x%08x", codecID) + + // Audio track should already be created in NewBridge + if b.AudioTrack == nil { + log.Printf("Audio track is nil! This should not happen.") + return + } + + // Start streaming audio packets + b.streamAudio(conn) +} + +// handleControlStream processes control stream +func (b *Bridge) handleControlStream(conn net.Conn) { + log.Println("Control stream handler started") + b.controlConn = conn + + // Update control handler with actual connection and screen dimensions + if b.controlHandler != nil { + b.controlHandler.UpdateConnection(conn) + // Update screen dimensions if available + if b.VideoWidth > 0 && b.VideoHeight > 0 { + b.controlHandler.UpdateScreenDimensions(b.VideoWidth, b.VideoHeight) + } + } else { + // Fallback: create new control handler if somehow not created + screenWidth := b.VideoWidth + screenHeight := b.VideoHeight + if screenWidth == 0 || screenHeight == 0 { + screenWidth = 1080 + screenHeight = 1920 + } + b.controlHandler = stream.NewControlHandler(conn, b.DataChannel, screenWidth, screenHeight) + b.controlHandler.HandleIncomingMessages() + } + + // Signal that control is ready (only once) + b.controlMutex.Lock() + select { + case <-b.controlReady: + // Already closed, do nothing + default: + close(b.controlReady) + } + b.controlMutex.Unlock() + + defer func() { + conn.Close() + b.controlConn = nil + b.controlHandler = nil + }() + + // Keep connection alive - don't try to read from it as it's write-only + // Just wait for context cancellation + <-b.Context.Done() + log.Println("Control connection closing due to context cancellation") +} + +// setupDataChannelHandlers sets up data channel event handlers +func (b *Bridge) setupDataChannelHandlers() { + // DataChannel will be set up when received via OnDataChannel + // This function is kept for future use if needed +} + +// DataChannel messages are now handled by ControlHandler +// This avoids duplication and ensures consistent message processing + +// handleKeyEvent delegates to control handler +func (b *Bridge) handleKeyEvent(message map[string]interface{}) { + // This is now handled by ControlHandler + // Keep for backward compatibility with WebSocket handlers +} + +// handleTouchEvent delegates to control handler +func (b *Bridge) handleTouchEvent(message map[string]interface{}) { + // This is now handled by ControlHandler + // Keep for backward compatibility with WebSocket handlers +} + +// requestKeyFrame requests a keyframe from the video encoder +func (b *Bridge) requestKeyFrame() { + if b.controlHandler != nil { + b.controlHandler.SendKeyFrameRequest() + } else { + log.Printf("Control handler not available for keyframe request") + } +} + +// Close closes the bridge and all its connections +func (b *Bridge) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.closed { + return nil + } + b.closed = true + + // Cancel context + if b.cancel != nil { + b.cancel() + } + + // Close WebRTC connection + if b.WebRTCConn != nil { + b.WebRTCConn.Close() + } + + // Close stream connections + if b.videoConn != nil { + b.videoConn.Close() + } + if b.audioConn != nil { + b.audioConn.Close() + } + if b.controlConn != nil { + b.controlConn.Close() + } + + // Close scrcpy connection + if b.scrcpyConn != nil { + b.scrcpyConn.Close() + } + + log.Printf("Closed WebRTC bridge for device %s", b.DeviceSerial) + return nil +} + +// createVideoTrack creates a WebRTC video track +func (b *Bridge) createVideoTrack(codecID uint32) error { + if b.VideoTrack != nil { + return nil + } + + var mimeType string + var rtpCodecCap webrtc.RTPCodecCapability + + // RTCP feedback for keyframe requests + videoRTCPFeedback := []webrtc.RTCPFeedback{ + {Type: "ccm", Parameter: "fir"}, + {Type: "nack", Parameter: "pli"}, + {Type: "goog-remb", Parameter: ""}, + } + + switch codecID { + case protocol.CodecIDH264: + mimeType = webrtc.MimeTypeH264 + rtpCodecCap = webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + RTCPFeedback: videoRTCPFeedback, + } + case protocol.CodecIDH265: + mimeType = webrtc.MimeTypeH265 + rtpCodecCap = webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH265, + ClockRate: 90000, + RTCPFeedback: videoRTCPFeedback, + } + case protocol.CodecIDAV1: + mimeType = webrtc.MimeTypeAV1 + rtpCodecCap = webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeAV1, + ClockRate: 90000, + RTCPFeedback: videoRTCPFeedback, + } + default: + return fmt.Errorf("unsupported video codec: 0x%08x", codecID) + } + + // Force specific H.264 profile for better compatibility + if codecID == protocol.CodecIDH264 { + // Use baseline profile for faster decoding + rtpCodecCap.SDPFmtpLine = "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f" + } + + videoTrack, err := webrtc.NewTrackLocalStaticSample(rtpCodecCap, "video", "scrcpy-video") + if err != nil { + return fmt.Errorf("failed to create video track: %w", err) + } + + if _, err := b.WebRTCConn.AddTrack(videoTrack); err != nil { + return fmt.Errorf("failed to add video track: %w", err) + } + + b.VideoTrack = videoTrack + log.Printf("Created video track with codec %s", mimeType) + return nil +} + +// createAudioTrack creates a WebRTC audio track +func (b *Bridge) createAudioTrack() error { + if b.AudioTrack != nil { + return nil + } + + audioTrack, err := webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, + ClockRate: 48000, + Channels: 2, + }, + "audio", + "scrcpy-audio", + ) + if err != nil { + return fmt.Errorf("failed to create audio track: %w", err) + } + + if _, err := b.WebRTCConn.AddTrack(audioTrack); err != nil { + return fmt.Errorf("failed to add audio track: %w", err) + } + + b.AudioTrack = audioTrack + log.Println("Created audio track") + return nil +} + +// streamVideoOptimized streams video packets to WebRTC +func (b *Bridge) streamVideoOptimized(reader io.Reader) { + var lastVideoTimestamp int64 = 0 + packetCount := 0 + var h264Sps []byte + var h264Pps []byte + startCode := []byte{0x00, 0x00, 0x00, 0x01} + decoderReady := false + firstFrameSent := false + + for { + select { + case <-b.Context.Done(): + return + default: + packet, err := device.ReadVideoPacket(reader) + if err != nil { + select { + case <-b.Context.Done(): + return + default: + log.Printf("Failed to read video packet #%d: %v", packetCount, err) + return + } + } + + packetCount++ + + if b.VideoTrack == nil { + log.Println("Video track not initialized") + return + } + + // Calculate duration between frames + timestamp := int64(packet.PTS) + var duration time.Duration + if lastVideoTimestamp > 0 && timestamp > lastVideoTimestamp { + duration = time.Duration(timestamp-lastVideoTimestamp) * time.Microsecond + duration = min(duration, 33*time.Millisecond) // Cap at 30 FPS + } + lastVideoTimestamp = timestamp + + // Process config packets for SPS/PPS + if packet.IsConfig && len(packet.Data) >= 8 { + data := addStartCodeIfNeeded(packet.Data) + spsPpsInfo := bytes.Split(data, startCode) + + if len(spsPpsInfo) >= 3 { + if len(spsPpsInfo[1]) > 0 { + // ALWAYS use 4-byte start code for Chrome compatibility + h264Sps = append([]byte{0x00, 0x00, 0x00, 0x01}, spsPpsInfo[1]...) + sample := media.Sample{ + Data: h264Sps, + Duration: 0, // Zero duration for immediate processing + } + if err := b.VideoTrack.WriteSample(sample); err != nil { + log.Printf("Failed to write SPS: %v", err) + } + } + if len(spsPpsInfo[2]) > 0 { + // ALWAYS use 4-byte start code for Chrome compatibility + h264Pps = append([]byte{0x00, 0x00, 0x00, 0x01}, spsPpsInfo[2]...) + sample := media.Sample{ + Data: h264Pps, + Duration: 0, // Zero duration for immediate processing + } + if err := b.VideoTrack.WriteSample(sample); err != nil { + log.Printf("Failed to write PPS: %v", err) + } + } + } + + // Mark decoder ready after config packets + if len(h264Sps) > 0 && len(h264Pps) > 0 { + decoderReady = true + log.Printf("SPS/PPS ready for decoding") + } + continue + } + + // For keyframes, send SPS/PPS first (only if not already sent) + if packet.IsKeyFrame && len(h264Sps) > 0 && len(h264Pps) > 0 { + // Send SPS/PPS before keyframe for proper decoding + sample := media.Sample{Data: h264Sps, Duration: 0} + b.VideoTrack.WriteSample(sample) + + sample = media.Sample{Data: h264Pps, Duration: 0} + b.VideoTrack.WriteSample(sample) + + decoderReady = true + } + + // Decide whether to send frame + shouldSendFrame := false + + if packet.IsKeyFrame { + shouldSendFrame = true + if !firstFrameSent { + firstFrameSent = true + decoderReady = true + log.Printf("First keyframe received") + } + } else if decoderReady { + shouldSendFrame = true + } + + if shouldSendFrame { + processedData := addStartCodeIfNeeded(packet.Data) + sample := media.Sample{ + Data: processedData, + Duration: duration, + // No timestamp for minimal latency + } + if err := b.VideoTrack.WriteSample(sample); err != nil { + log.Printf("Failed to write video sample: %v", err) + return + } + + // Keyframes are now requested only when needed: + // - On connection establishment (handled in setupDataChannel) + // - On video size changes (handled by frontend) + // - On manual user requests + // Removed periodic keyframe requests to reduce log spam + } + } + } +} + +// streamAudio streams audio packets to WebRTC +func (b *Bridge) streamAudio(conn net.Conn) { + packetCount := 0 + + for { + select { + case <-b.Context.Done(): + return + default: + packet, err := device.ReadAudioPacket(conn) + if err != nil { + log.Printf("Failed to read audio packet #%d: %v", packetCount, err) + return + } + + packetCount++ + + if b.AudioTrack == nil { + log.Println("Audio track not initialized") + return + } + + // For Opus, use fixed 20ms frame duration + sample := media.Sample{ + Data: packet.Data, + Duration: 20 * time.Millisecond, + } + if err := b.AudioTrack.WriteSample(sample); err != nil { + log.Printf("Failed to write audio sample: %v", err) + return + } + } + } +} + +// Helper function to add H.264 start codes +func addStartCodeIfNeeded(data []byte) []byte { + if len(data) < 4 { + return data + } + + startCode3 := []byte{0x00, 0x00, 0x01} + startCode4 := []byte{0x00, 0x00, 0x00, 0x01} + + if len(data) >= 4 && bytes.Equal(data[:4], startCode4) { + return data + } + + if len(data) >= 3 && bytes.Equal(data[:3], startCode3) { + result := make([]byte, 0, len(data)+1) + result = append(result, startCode4...) + result = append(result, data[3:]...) + return result + } + + result := make([]byte, 0, len(data)+4) + result = append(result, startCode4...) + result = append(result, data...) + return result +} + +// Helper function for min +func min(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +// SendControlMessage delegates to control handler +func (b *Bridge) SendControlMessage(msg *device.ControlMessage) error { + // This is now handled by ControlHandler + // Keep for backward compatibility + if b.controlHandler != nil { + // ControlHandler handles the actual sending + return nil + } + return fmt.Errorf("control handler not available") +} + +// HandleTouchEvent handles touch events from WebSocket +func (b *Bridge) HandleTouchEvent(message map[string]interface{}) { + if b.controlHandler != nil { + // Extract touch event parameters and delegate to control handler + action, _ := message["action"].(string) + x, _ := message["x"].(float64) + y, _ := message["y"].(float64) + pressure, _ := message["pressure"].(float64) + pointerId, _ := message["pointerId"].(float64) + + b.controlHandler.SendTouchEventToDevice(action, x, y, pressure, int(pointerId)) + } else { + log.Printf("Control handler not available for touch event") + } +} + +// HandleKeyEvent handles key events from WebSocket +func (b *Bridge) HandleKeyEvent(message map[string]interface{}) { + if b.controlHandler != nil { + // Extract key event parameters and delegate to control handler + action, _ := message["action"].(string) + keycode, _ := message["keycode"].(float64) + metaState, _ := message["metaState"].(float64) + repeat, _ := message["repeat"].(float64) + + b.controlHandler.SendKeyEventToDevice(action, int(keycode), int(metaState), int(repeat)) + } else { + log.Printf("Control handler not available for key event") + } +} + +// HandleScrollEvent handles scroll events from WebSocket +func (b *Bridge) HandleScrollEvent(message map[string]interface{}) { + if b.controlHandler != nil { + // Extract scroll event parameters and delegate to control handler + x, _ := message["x"].(float64) + y, _ := message["y"].(float64) + hScroll, _ := message["hScroll"].(float64) + vScroll, _ := message["vScroll"].(float64) + + b.controlHandler.SendScrollEventToDevice(x, y, hScroll, vScroll) + } else { + log.Printf("Control handler not available for scroll event") + } +} + +// handleScrollEvent delegates to control handler +func (b *Bridge) handleScrollEvent(message map[string]interface{}) { + // This is now handled by ControlHandler + // Keep for backward compatibility with WebSocket handlers +} diff --git a/packages/cli/internal/device_connect/webrtc/debug.go b/packages/cli/internal/device_connect/webrtc/debug.go new file mode 100644 index 00000000..203f1e18 --- /dev/null +++ b/packages/cli/internal/device_connect/webrtc/debug.go @@ -0,0 +1,25 @@ +package webrtc + +import ( + "encoding/hex" + "io" + "log" +) + +// DebugReader wraps an io.Reader to log what's being read +type DebugReader struct { + reader io.Reader + name string +} + +func NewDebugReader(reader io.Reader, name string) *DebugReader { + return &DebugReader{reader: reader, name: name} +} + +func (d *DebugReader) Read(p []byte) (n int, err error) { + n, err = d.reader.Read(p) + if n > 0 { + log.Printf("[%s] Read %d bytes: %s", d.name, n, hex.EncodeToString(p[:n])) + } + return n, err +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/webrtc/manager.go b/packages/cli/internal/device_connect/webrtc/manager.go new file mode 100644 index 00000000..ccc1bb67 --- /dev/null +++ b/packages/cli/internal/device_connect/webrtc/manager.go @@ -0,0 +1,86 @@ +package webrtc + +import ( + "log" + "sync" +) + +// Manager manages WebRTC bridges for multiple devices +type Manager struct { + bridges map[string]*Bridge + mu sync.RWMutex + adbPath string +} + +// NewManager creates a new WebRTC manager +func NewManager(adbPath string) *Manager { + return &Manager{ + bridges: make(map[string]*Bridge), + adbPath: adbPath, + } +} + +// CreateBridge creates a new WebRTC bridge for a device +func (m *Manager) CreateBridge(deviceSerial string) (*Bridge, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Remove existing bridge if any + if existing, exists := m.bridges[deviceSerial]; exists { + existing.Close() + delete(m.bridges, deviceSerial) + } + + // Create new bridge + bridge, err := NewBridge(deviceSerial, m.adbPath) + if err != nil { + return nil, err + } + + // Start the bridge + if err := bridge.Start(); err != nil { + bridge.Close() + return nil, err + } + + m.bridges[deviceSerial] = bridge + log.Printf("Created WebRTC bridge for device %s", deviceSerial) + + return bridge, nil +} + +// GetBridge returns an existing bridge for a device +func (m *Manager) GetBridge(deviceSerial string) (*Bridge, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + bridge, exists := m.bridges[deviceSerial] + return bridge, exists +} + +// RemoveBridge removes and closes a bridge for a device +func (m *Manager) RemoveBridge(deviceSerial string) { + m.mu.Lock() + defer m.mu.Unlock() + + if bridge, exists := m.bridges[deviceSerial]; exists { + bridge.Close() + delete(m.bridges, deviceSerial) + log.Printf("Removed WebRTC bridge for device %s", deviceSerial) + } +} + +// Close closes all bridges +func (m *Manager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + for serial, bridge := range m.bridges { + if err := bridge.Close(); err != nil { + log.Printf("Error closing bridge for device %s: %v", serial, err) + } + } + + m.bridges = make(map[string]*Bridge) + return nil +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/webrtc/peer_connection.go b/packages/cli/internal/device_connect/webrtc/peer_connection.go new file mode 100644 index 00000000..e356d82c --- /dev/null +++ b/packages/cli/internal/device_connect/webrtc/peer_connection.go @@ -0,0 +1,136 @@ +package webrtc + +import ( + "fmt" + "log" + "time" + + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media" +) + +// PeerConnectionConfig contains WebRTC peer connection configuration +type PeerConnectionConfig struct { + VideoCodec string + AudioCodec string +} + +// CreatePeerConnection creates a new WebRTC peer connection +func CreatePeerConnection() (*webrtc.PeerConnection, error) { + // Create a MediaEngine with codecs + m := &webrtc.MediaEngine{} + + // Register video codecs + if err := m.RegisterCodec(webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", + }, + PayloadType: 96, + }, webrtc.RTPCodecTypeVideo); err != nil { + return nil, err + } + + // Register audio codecs + if err := m.RegisterCodec(webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, + ClockRate: 48000, + Channels: 2, + }, + PayloadType: 111, + }, webrtc.RTPCodecTypeAudio); err != nil { + return nil, err + } + + // Create the API with MediaEngine + api := webrtc.NewAPI(webrtc.WithMediaEngine(m)) + + // Create a new RTCPeerConnection with low latency configuration + config := webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{}, + } + + pc, err := api.NewPeerConnection(config) + if err != nil { + return nil, fmt.Errorf("failed to create peer connection: %w", err) + } + + // Set up connection state logging + pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) { + log.Printf("WebRTC Connection State: %s", s.String()) + }) + + pc.OnICEConnectionStateChange(func(s webrtc.ICEConnectionState) { + log.Printf("ICE Connection State: %s", s.String()) + }) + + return pc, nil +} + +// AddVideoTrack adds a video track to the peer connection +func AddVideoTrack(pc *webrtc.PeerConnection, codecType string) (*webrtc.TrackLocalStaticSample, error) { + var videoTrack *webrtc.TrackLocalStaticSample + var err error + + switch codecType { + case "h264": + videoTrack, err = webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, + "video", + "android-screen", + ) + default: + return nil, fmt.Errorf("unsupported video codec: %s", codecType) + } + + if err != nil { + return nil, fmt.Errorf("failed to create video track: %w", err) + } + + if _, err = pc.AddTrack(videoTrack); err != nil { + return nil, fmt.Errorf("failed to add video track: %w", err) + } + + log.Printf("Added %s video track", codecType) + return videoTrack, nil +} + +// AddAudioTrack adds an audio track to the peer connection +func AddAudioTrack(pc *webrtc.PeerConnection, codecType string) (*webrtc.TrackLocalStaticSample, error) { + var audioTrack *webrtc.TrackLocalStaticSample + var err error + + switch codecType { + case "opus": + audioTrack, err = webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, + "audio", + "android-audio", + ) + default: + return nil, fmt.Errorf("unsupported audio codec: %s", codecType) + } + + if err != nil { + return nil, fmt.Errorf("failed to create audio track: %w", err) + } + + if _, err = pc.AddTrack(audioTrack); err != nil { + return nil, fmt.Errorf("failed to add audio track: %w", err) + } + + log.Printf("Added %s audio track", codecType) + return audioTrack, nil +} + +// WriteSample writes a media sample to a track +func WriteSample(track *webrtc.TrackLocalStaticSample, data []byte, duration uint32) error { + sample := media.Sample{ + Data: data, + Duration: time.Duration(duration) * time.Nanosecond, + } + + return track.WriteSample(sample) +} \ No newline at end of file diff --git a/packages/cli/internal/server/adb_expose.go b/packages/cli/internal/server/adb_expose.go new file mode 100644 index 00000000..2fccb9e3 --- /dev/null +++ b/packages/cli/internal/server/adb_expose.go @@ -0,0 +1,327 @@ +package server + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os/exec" + "strconv" + "strings" + "sync" +) + +// ADBExposeService manages ADB port forwarding +type ADBExposeService struct { + mu sync.RWMutex + forwards map[string]*PortForward // key: "device:localPort:remotePort" + running bool +} + +// PortForward represents an active port forward +type PortForward struct { + DeviceSerial string `json:"device_serial"` + LocalPort int `json:"local_port"` + RemotePort int `json:"remote_port"` + Protocol string `json:"protocol"` // tcp or unix + Active bool `json:"active"` +} + +// NewADBExposeService creates a new ADB expose service +func NewADBExposeService() *ADBExposeService { + return &ADBExposeService{ + forwards: make(map[string]*PortForward), + } +} + +// Start starts the ADB expose service +func (s *ADBExposeService) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.running = true + log.Println("ADB Expose service started") + return nil +} + +// Stop stops the ADB expose service +func (s *ADBExposeService) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + // Clear all forwards + for key, forward := range s.forwards { + if err := s.removeForward(forward); err != nil { + log.Printf("Failed to remove forward %s: %v", key, err) + } + } + + s.forwards = make(map[string]*PortForward) + s.running = false + log.Println("ADB Expose service stopped") + return nil +} + +// Close closes the service +func (s *ADBExposeService) Close() error { + return s.Stop() +} + +// IsRunning returns whether the service is running +func (s *ADBExposeService) IsRunning() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.running +} + +// AddForward adds a new port forward +func (s *ADBExposeService) AddForward(deviceSerial string, localPort, remotePort int, protocol string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running { + return fmt.Errorf("service not running") + } + + key := fmt.Sprintf("%s:%d:%d", deviceSerial, localPort, remotePort) + + // Check if already exists + if _, exists := s.forwards[key]; exists { + return fmt.Errorf("forward already exists") + } + + // Execute adb forward command + var cmd *exec.Cmd + if deviceSerial == "" || deviceSerial == "default" { + cmd = exec.Command("adb", "forward", + fmt.Sprintf("tcp:%d", localPort), + fmt.Sprintf("%s:%d", protocol, remotePort)) + } else { + cmd = exec.Command("adb", "-s", deviceSerial, "forward", + fmt.Sprintf("tcp:%d", localPort), + fmt.Sprintf("%s:%d", protocol, remotePort)) + } + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add forward: %w", err) + } + + forward := &PortForward{ + DeviceSerial: deviceSerial, + LocalPort: localPort, + RemotePort: remotePort, + Protocol: protocol, + Active: true, + } + + s.forwards[key] = forward + log.Printf("Added forward: %s", key) + + return nil +} + +// RemoveForward removes a port forward +func (s *ADBExposeService) RemoveForward(deviceSerial string, localPort, remotePort int) error { + s.mu.Lock() + defer s.mu.Unlock() + + key := fmt.Sprintf("%s:%d:%d", deviceSerial, localPort, remotePort) + + forward, exists := s.forwards[key] + if !exists { + return fmt.Errorf("forward not found") + } + + if err := s.removeForward(forward); err != nil { + return err + } + + delete(s.forwards, key) + log.Printf("Removed forward: %s", key) + + return nil +} + +// removeForward executes adb command to remove forward +func (s *ADBExposeService) removeForward(forward *PortForward) error { + var cmd *exec.Cmd + if forward.DeviceSerial == "" || forward.DeviceSerial == "default" { + cmd = exec.Command("adb", "forward", "--remove", + fmt.Sprintf("tcp:%d", forward.LocalPort)) + } else { + cmd = exec.Command("adb", "-s", forward.DeviceSerial, "forward", "--remove", + fmt.Sprintf("tcp:%d", forward.LocalPort)) + } + + return cmd.Run() +} + +// ListForwards returns all active forwards +func (s *ADBExposeService) ListForwards() []*PortForward { + s.mu.RLock() + defer s.mu.RUnlock() + + forwards := make([]*PortForward, 0, len(s.forwards)) + for _, forward := range s.forwards { + forwards = append(forwards, forward) + } + + return forwards +} + +// HTTP Handlers for ADB Expose + +func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + DeviceSerial string `json:"device_serial"` + LocalPort int `json:"local_port"` + RemotePort int `json:"remote_port"` + Protocol string `json:"protocol"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondJSON(w, http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + return + } + + // Default protocol to tcp + if req.Protocol == "" { + req.Protocol = "tcp" + } + + // Start service if not running + if !s.adbExpose.IsRunning() { + if err := s.adbExpose.Start(); err != nil { + respondJSON(w, http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + return + } + } + + // Add forward + if err := s.adbExpose.AddForward(req.DeviceSerial, req.LocalPort, req.RemotePort, req.Protocol); err != nil { + respondJSON(w, http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("Port forward added: %d -> %d", req.LocalPort, req.RemotePort), + }) +} + +func (s *GBoxServer) handleADBExposeStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + DeviceSerial string `json:"device_serial"` + LocalPort int `json:"local_port"` + RemotePort int `json:"remote_port"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // If no body, stop all forwards + if err := s.adbExpose.Stop(); err != nil { + respondJSON(w, http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "message": "All port forwards removed", + }) + return + } + + // Remove specific forward + if err := s.adbExpose.RemoveForward(req.DeviceSerial, req.LocalPort, req.RemotePort); err != nil { + respondJSON(w, http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "message": "Port forward removed", + }) +} + +func (s *GBoxServer) handleADBExposeStatus(w http.ResponseWriter, r *http.Request) { + status := map[string]interface{}{ + "running": s.adbExpose.IsRunning(), + "forwards": s.adbExpose.ListForwards(), + } + + respondJSON(w, http.StatusOK, status) +} + +func (s *GBoxServer) handleADBExposeList(w http.ResponseWriter, r *http.Request) { + // Get all adb forwards from system + cmd := exec.Command("adb", "forward", "--list") + output, err := cmd.Output() + if err != nil { + respondJSON(w, http.StatusInternalServerError, map[string]string{ + "error": fmt.Sprintf("Failed to list forwards: %v", err), + }) + return + } + + // Parse output + lines := strings.Split(string(output), "\n") + forwards := []map[string]interface{}{} + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Format: serial tcp:local tcp:remote + parts := strings.Fields(line) + if len(parts) >= 3 { + localParts := strings.Split(parts[1], ":") + remoteParts := strings.Split(parts[2], ":") + + forward := map[string]interface{}{ + "device_serial": parts[0], + "local": parts[1], + "remote": parts[2], + } + + // Try to parse ports + if len(localParts) == 2 { + if port, err := strconv.Atoi(localParts[1]); err == nil { + forward["local_port"] = port + } + } + if len(remoteParts) == 2 { + if port, err := strconv.Atoi(remoteParts[1]); err == nil { + forward["remote_port"] = port + } + } + + forwards = append(forwards, forward) + } + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "forwards": forwards, + "managed": s.adbExpose.ListForwards(), + }) +} \ No newline at end of file diff --git a/packages/cli/internal/server/device_connect.go b/packages/cli/internal/server/device_connect.go new file mode 100644 index 00000000..3268a47f --- /dev/null +++ b/packages/cli/internal/server/device_connect.go @@ -0,0 +1,497 @@ +package server + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os/exec" + "strings" + + "github.com/gorilla/websocket" + "github.com/pion/webrtc/v4" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins for now + }, +} + +// Device Connect API handlers + +func (s *GBoxServer) handleDevices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + devices, err := s.getADBDevices() + 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 + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "devices": devices, + "onDemandEnabled": true, + }) +} + +func (s *GBoxServer) handleDeviceAction(w http.ResponseWriter, r *http.Request) { + // Parse URL path: /api/devices/{id}/{action} + path := strings.TrimPrefix(r.URL.Path, "/api/devices/") + parts := strings.Split(path, "/") + + if len(parts) != 2 { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + deviceID := parts[0] + action := parts[1] + + switch action { + case "connect": + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s.handleDeviceConnect(w, r, deviceID) + case "disconnect": + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s.handleDeviceDisconnect(w, r, deviceID) + default: + http.Error(w, "Unknown action", http.StatusNotFound) + } +} + +func (s *GBoxServer) handleDeviceConnect(w http.ResponseWriter, r *http.Request, deviceID string) { + // Register the device with WebRTC manager + bridge, err := s.webrtcManager.CreateBridge(deviceID) + if err != nil { + log.Printf("Failed to create bridge for device %s: %v", deviceID, err) + respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "deviceId": deviceID, + "bridgeId": bridge.DeviceSerial, + "message": "Device connected successfully", + }) +} + +func (s *GBoxServer) handleDeviceDisconnect(w http.ResponseWriter, r *http.Request, deviceID string) { + // Unregister the device from WebRTC manager + s.webrtcManager.RemoveBridge(deviceID) + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "deviceId": deviceID, + "message": "Device disconnected successfully", + }) +} + +func (s *GBoxServer) handleRegisterDevice(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + DeviceID string `json:"deviceId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondJSON(w, http.StatusBadRequest, map[string]interface{}{ + "success": false, + "error": "Invalid request body", + }) + return + } + + bridge, err := s.webrtcManager.CreateBridge(req.DeviceID) + if err != nil { + log.Printf("Failed to create bridge for device %s: %v", req.DeviceID, err) + respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + log.Printf("Successfully registered device %s", req.DeviceID) + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "device_id": bridge.DeviceSerial, + "message": "Device registered successfully", + }) +} + +func (s *GBoxServer) handleUnregisterDevice(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + DeviceID string `json:"deviceId"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondJSON(w, http.StatusBadRequest, map[string]interface{}{ + "success": false, + "error": "Invalid request body", + }) + return + } + + s.webrtcManager.RemoveBridge(req.DeviceID) + + log.Printf("Successfully unregistered device %s", req.DeviceID) + respondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "message": "Device unregistered successfully", + }) +} + +func (s *GBoxServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { + // Proxy WebSocket to device_connect server + // For now, use the existing webrtc manager logic + // TODO: Implement proper proxy to device_connect server + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("Failed to upgrade WebSocket: %v", err) + return + } + defer conn.Close() + + log.Println("WebSocket connection established (main server)") + + for { + var msg map[string]interface{} + if err := conn.ReadJSON(&msg); err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("WebSocket read error: %v", err) + } + break + } + + msgType, ok := msg["type"].(string) + if !ok { + continue + } + + switch msgType { + case "connect": + s.handleWebSocketConnect(conn, msg) + case "offer": + s.handleWebSocketOffer(conn, msg) + case "ice-candidate": + s.handleWebSocketICECandidate(conn, msg) + case "disconnect": + s.handleWebSocketDisconnect(conn, msg) + } + } +} + +func (s *GBoxServer) handleWebSocketConnect(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": "Device serial required", + }) + return + } + + bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + if !exists { + var err error + bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + if err != nil { + log.Printf("Failed to create bridge: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": err.Error(), + }) + return + } + } + + bridge.WSConnection = conn + + conn.WriteJSON(map[string]interface{}{ + "type": "connected", + "deviceSerial": deviceSerial, + }) +} + +func (s *GBoxServer) handleWebSocketOffer(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + return + } + + offerData, ok := msg["offer"].(map[string]interface{}) + if !ok { + return + } + + sdp, ok := offerData["sdp"].(string) + if !ok { + return + } + + // Get or create bridge for the device + bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + if !exists { + log.Printf("Bridge not found for device %s, creating new bridge", deviceSerial) + var err error + bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + if err != nil { + log.Printf("Failed to create bridge: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": fmt.Sprintf("Failed to connect to device: %v", err), + }) + return + } + } + + // Check signaling state - only recreate if truly necessary + signalingState := bridge.WebRTCConn.SignalingState() + connState := bridge.WebRTCConn.ConnectionState() + + log.Printf("Bridge state for device %s: signaling=%s, connection=%s", deviceSerial, signalingState, connState) + + // Only recreate bridge if connection is truly closed or failed + if connState == webrtc.PeerConnectionStateClosed || connState == webrtc.PeerConnectionStateFailed { + log.Printf("WebRTC connection is %s for device %s, recreating bridge", connState, deviceSerial) + s.webrtcManager.RemoveBridge(deviceSerial) + + // Create new bridge + var err error + bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + if err != nil { + log.Printf("Failed to recreate bridge: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": fmt.Sprintf("Failed to reconnect to device: %v", err), + }) + return + } + } else if signalingState == webrtc.SignalingStateClosed { + // Only recreate if signaling is closed but connection is still active + log.Printf("Signaling state is closed for device %s, recreating bridge", deviceSerial) + s.webrtcManager.RemoveBridge(deviceSerial) + + var err error + bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + if err != nil { + log.Printf("Failed to recreate bridge: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": fmt.Sprintf("Failed to reset connection: %v", err), + }) + return + } + } + + offer := webrtc.SessionDescription{ + Type: webrtc.SDPTypeOffer, + SDP: sdp, + } + + if err := bridge.WebRTCConn.SetRemoteDescription(offer); err != nil { + log.Printf("Failed to set remote description: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": err.Error(), + }) + return + } + + answer, err := bridge.WebRTCConn.CreateAnswer(nil) + if err != nil { + log.Printf("Failed to create answer: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": err.Error(), + }) + return + } + + if err := bridge.WebRTCConn.SetLocalDescription(answer); err != nil { + log.Printf("Failed to set local description: %v", err) + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": err.Error(), + }) + return + } + + conn.WriteJSON(map[string]interface{}{ + "type": "answer", + "answer": map[string]interface{}{ + "type": "answer", + "sdp": answer.SDP, + }, + }) + + bridge.WebRTCConn.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate == nil { + return + } + + candidateJSON := candidate.ToJSON() + conn.WriteJSON(map[string]interface{}{ + "type": "ice-candidate", + "candidate": map[string]interface{}{ + "candidate": candidateJSON.Candidate, + "sdpMLineIndex": candidateJSON.SDPMLineIndex, + "sdpMid": candidateJSON.SDPMid, + }, + }) + }) + + // Device info is not needed by frontend, video dimensions will be available through video track +} + +func (s *GBoxServer) handleWebSocketICECandidate(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + return + } + + candidateData, ok := msg["candidate"].(map[string]interface{}) + if !ok { + return + } + + bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + if !exists { + return + } + + candidate := webrtc.ICECandidateInit{ + Candidate: candidateData["candidate"].(string), + } + + if sdpMLineIndex, ok := candidateData["sdpMLineIndex"].(float64); ok { + index := uint16(sdpMLineIndex) + candidate.SDPMLineIndex = &index + } + + if sdpMid, ok := candidateData["sdpMid"].(string); ok { + candidate.SDPMid = &sdpMid + } + + if err := bridge.WebRTCConn.AddICECandidate(candidate); err != nil { + log.Printf("Failed to add ICE candidate: %v", err) + } +} + +func (s *GBoxServer) handleWebSocketDisconnect(conn *websocket.Conn, msg map[string]interface{}) { + deviceSerial, ok := msg["deviceSerial"].(string) + if !ok { + return + } + + s.webrtcManager.RemoveBridge(deviceSerial) + + conn.WriteJSON(map[string]interface{}{ + "type": "disconnected", + }) +} + +func (s *GBoxServer) getADBDevices() ([]map[string]interface{}, error) { + adbPath, err := exec.LookPath("adb") + if err != nil { + return nil, fmt.Errorf("adb not found in PATH") + } + + cmd := exec.Command(adbPath, "devices", "-l") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to run adb devices: %w", err) + } + + lines := strings.Split(string(output), "\n") + devices := []map[string]interface{}{} + + for _, line := range lines[1:] { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + serial := parts[0] + state := parts[1] + + if state != "device" { + continue + } + + device := map[string]interface{}{ + "id": serial, + "udid": serial, + "state": state, + "ro.serialno": serial, + "connectionType": "usb", + "isRegistrable": false, + } + + if strings.Contains(line, "model:") { + if idx := strings.Index(line, "model:"); idx != -1 { + modelPart := line[idx+6:] + if spaceIdx := strings.Index(modelPart, " "); spaceIdx != -1 { + device["ro.product.model"] = modelPart[:spaceIdx] + } else { + device["ro.product.model"] = modelPart + } + } + } + + if strings.Contains(line, "device:") { + if idx := strings.Index(line, "device:"); idx != -1 { + devicePart := line[idx+7:] + if spaceIdx := strings.Index(devicePart, " "); spaceIdx != -1 { + device["ro.product.manufacturer"] = devicePart[:spaceIdx] + } + } + } + + if strings.Contains(serial, ":") { + device["connectionType"] = "ip" + } + + if _, exists := s.webrtcManager.GetBridge(serial); exists { + device["isRegistrable"] = true + } + + devices = append(devices, device) + } + + return devices, nil +} diff --git a/packages/cli/internal/server/server.go b/packages/cli/internal/server/server.go new file mode 100644 index 00000000..3cf28a37 --- /dev/null +++ b/packages/cli/internal/server/server.go @@ -0,0 +1,415 @@ +package server + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/webrtc" +) + +//go:embed all:static +var staticFiles embed.FS + +// GBoxServer is the unified server for all gbox services +type GBoxServer struct { + port int + httpServer *http.Server + mux *http.ServeMux + + // Services + webrtcManager *webrtc.Manager + adbExpose *ADBExposeService + + // State + mu sync.RWMutex + running bool + ctx context.Context + cancel context.CancelFunc +} + +// NewGBoxServer creates a new unified gbox server +func NewGBoxServer(port int) *GBoxServer { + ctx, cancel := context.WithCancel(context.Background()) + + return &GBoxServer{ + port: port, + mux: http.NewServeMux(), + webrtcManager: webrtc.NewManager("adb"), + adbExpose: NewADBExposeService(), + ctx: ctx, + cancel: cancel, + } +} + +// Start starts the unified server +func (s *GBoxServer) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + return fmt.Errorf("server already running") + } + + // Setup routes + s.setupRoutes() + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: s.mux, + } + + // Start server in background + go func() { + log.Printf("Starting GBox server on port %d", s.port) + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("HTTP server error: %v", err) + } + }() + + s.running = true + log.Printf("GBox server started successfully on http://localhost:%d", s.port) + return nil +} + +// Stop stops the server +func (s *GBoxServer) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running { + return nil + } + + s.cancel() + + // Shutdown HTTP server + if s.httpServer != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.httpServer.Shutdown(ctx); err != nil { + log.Printf("HTTP server shutdown error: %v", err) + } + } + + // Cleanup services + s.webrtcManager.Close() + s.adbExpose.Close() + + s.running = false + log.Println("GBox server stopped") + return nil +} + +// IsRunning returns whether the server is running +func (s *GBoxServer) IsRunning() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.running +} + +// setupRoutes sets up all HTTP routes +func (s *GBoxServer) setupRoutes() { + // Health check + s.mux.HandleFunc("/health", s.handleHealth) + s.mux.HandleFunc("/api/status", s.handleStatus) + + // Device Connect API (scrcpy/WebRTC) + s.mux.HandleFunc("/api/devices", s.handleDevices) + s.mux.HandleFunc("/api/devices/", s.handleDeviceAction) // Handles /api/devices/{id}/connect and /api/devices/{id}/disconnect + s.mux.HandleFunc("/api/devices/register", s.handleRegisterDevice) + s.mux.HandleFunc("/api/devices/unregister", s.handleUnregisterDevice) + s.mux.HandleFunc("/ws", s.handleWebSocket) + + // ADB Expose API + s.mux.HandleFunc("/api/adb-expose/start", s.handleADBExposeStart) + s.mux.HandleFunc("/api/adb-expose/stop", s.handleADBExposeStop) + s.mux.HandleFunc("/api/adb-expose/status", s.handleADBExposeStatus) + s.mux.HandleFunc("/api/adb-expose/list", s.handleADBExposeList) + + // Server management API + s.mux.HandleFunc("/api/server/shutdown", s.handleShutdown) + s.mux.HandleFunc("/api/server/info", s.handleServerInfo) + + // Sub-applications - handle both with and without trailing slash + s.mux.HandleFunc("/live-view", s.handleLiveView) + s.mux.HandleFunc("/live-view/", s.handleLiveView) + s.mux.HandleFunc("/live-view.html", s.handleLiveViewHTML) + s.mux.HandleFunc("/adb-expose", s.handleAdbExposeUI) + s.mux.HandleFunc("/adb-expose/", s.handleAdbExposeUI) + + // Static files and web UI routes - must be last + s.setupStaticFiles() +} + +// setupStaticFiles sets up static file serving +func (s *GBoxServer) setupStaticFiles() { + // First, try to serve live-view static files if available + liveViewPath := s.findLiveViewStaticPath() + if liveViewPath != "" { + // Serve assets directory for CSS/JS files + assetsPath := filepath.Join(liveViewPath, "assets") + if _, err := os.Stat(assetsPath); err == nil { + s.mux.Handle("/assets/", http.StripPrefix("/assets/", s.serveStaticWithMIME(http.Dir(assetsPath)))) + } + // Also handle root static files + s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(liveViewPath)))) + } + + // Try embedded files + staticFS, err := fs.Sub(staticFiles, "static") + if err == nil { + s.mux.Handle("/", http.FileServer(http.FS(staticFS))) + log.Println("Serving embedded static files") + } else { + // Fallback to a simple status page + s.mux.HandleFunc("/", s.handleRoot) + } +} + +// serveStaticWithMIME wraps a file server to set correct MIME types +func (s *GBoxServer) serveStaticWithMIME(fs http.FileSystem) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set correct MIME types based on file extension + if strings.HasSuffix(r.URL.Path, ".css") { + w.Header().Set("Content-Type", "text/css") + } else if strings.HasSuffix(r.URL.Path, ".js") { + w.Header().Set("Content-Type", "application/javascript") + } else if strings.HasSuffix(r.URL.Path, ".html") { + w.Header().Set("Content-Type", "text/html") + } + http.FileServer(fs).ServeHTTP(w, r) + }) +} + +// findLiveViewStaticPath finds the live-view build output +func (s *GBoxServer) findLiveViewStaticPath() string { + // Try various possible locations for the live-view static files + possiblePaths := []string{ + // Relative to gbox binary location + "../../live-view/static", + "../live-view/static", + "packages/live-view/static", + // In gbox workspace + "/Users/duwan/Workspaces/babelcloud/gbox/packages/live-view/static", + // In user's home directory + filepath.Join(os.Getenv("HOME"), ".gbox", "live-view-static"), + // Development paths + "./packages/live-view/static", + "../../../gbox/packages/live-view/static", + } + + for _, path := range possiblePaths { + absPath, err := filepath.Abs(path) + if err != nil { + continue + } + if info, err := os.Stat(absPath); err == nil && info.IsDir() { + if _, err := os.Stat(filepath.Join(absPath, "index.html")); err == nil { + log.Printf("Found live-view static files at: %s", absPath) + return absPath + } + } + } + + log.Printf("Warning: Live-view static files not found, using default status page") + return "" +} + +// API Handlers + +func (s *GBoxServer) handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +func (s *GBoxServer) handleStatus(w http.ResponseWriter, r *http.Request) { + status := map[string]interface{}{ + "running": s.IsRunning(), + "port": s.port, + "services": map[string]interface{}{ + "device_connect": true, + "adb_expose": s.adbExpose.IsRunning(), + }, + "version": "1.0.0", + } + + respondJSON(w, http.StatusOK, status) +} + +func (s *GBoxServer) handleRoot(w http.ResponseWriter, r *http.Request) { + // Only serve root page for exact path match + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + html := ` + + + GBox Server + + + + +
+ 🟢 Server Running +
+ +
+
+

GBox Server

+

Choose a service to continue

+
+ + +
+ +` + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, html) +} + +func (s *GBoxServer) handleShutdown(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + respondJSON(w, http.StatusOK, map[string]string{ + "message": "Server shutting down", + }) + + // Shutdown after response + go func() { + time.Sleep(100 * time.Millisecond) + s.Stop() + os.Exit(0) + }() +} + +func (s *GBoxServer) handleServerInfo(w http.ResponseWriter, r *http.Request) { + info := map[string]interface{}{ + "version": "1.0.0", + "port": s.port, + "uptime": time.Since(time.Now()).String(), // TODO: track actual start time + "services": []string{ + "device-connect", + "adb-expose", + }, + } + + respondJSON(w, http.StatusOK, info) +} + +// Helper function to send JSON responses +func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(data) +} \ No newline at end of file diff --git a/packages/cli/internal/server/static/index.html b/packages/cli/internal/server/static/index.html new file mode 100644 index 00000000..f2cf0d05 --- /dev/null +++ b/packages/cli/internal/server/static/index.html @@ -0,0 +1,118 @@ + + + + + + GBox Server + + + +
+ 🟢 Server Running +
+ +
+
+

GBox Server

+

Choose a service to continue

+
+ + +
+ + \ No newline at end of file diff --git a/packages/cli/internal/server/ui_handlers.go b/packages/cli/internal/server/ui_handlers.go new file mode 100644 index 00000000..98d53393 --- /dev/null +++ b/packages/cli/internal/server/ui_handlers.go @@ -0,0 +1,399 @@ +package server + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" +) + +// handleLiveViewHTML serves the live-view.html file +func (s *GBoxServer) handleLiveViewHTML(w http.ResponseWriter, r *http.Request) { + // Try to find and serve the live-view static files + liveViewPath := s.findLiveViewStaticPath() + if liveViewPath != "" { + htmlFile := filepath.Join(liveViewPath, "index.html") + if _, err := os.Stat(htmlFile); err == nil { + // Read and serve the file with correct MIME type + file, err := os.Open(htmlFile) + if err == nil { + defer file.Close() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + io.Copy(w, file) + return + } + } + } + + // If live-view is not built, redirect to /live-view + http.Redirect(w, r, "/live-view", http.StatusTemporaryRedirect) +} + +// handleLiveView serves the live-view application +func (s *GBoxServer) handleLiveView(w http.ResponseWriter, r *http.Request) { + // Try to find and serve the live-view static files + liveViewPath := s.findLiveViewStaticPath() + if liveViewPath != "" { + htmlFile := filepath.Join(liveViewPath, "index.html") + if _, err := os.Stat(htmlFile); err == nil { + // Read and serve the file with correct MIME type + file, err := os.Open(htmlFile) + if err == nil { + defer file.Close() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + io.Copy(w, file) + return + } + } + } + + // If live-view is not built, show a placeholder + html := ` + + + Live View - GBox + + + + +
+

📱 Live View

+
+

The Live View interface is not yet built.

+

To enable the full WebRTC streaming interface:

+ cd packages/live-view && pnpm install && pnpm build:static +

+ After building, restart the server to load the interface. +

+
+ ← Back to Home +
+ +` + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, html) +} + +// handleAdbExposeUI serves the ADB Expose management interface +func (s *GBoxServer) handleAdbExposeUI(w http.ResponseWriter, r *http.Request) { + html := ` + + + ADB Expose - GBox + + + + +
+

🔌 ADB Expose

+ ← Back to Home +
+ +
+
+

Add Port Forward

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+

Active Port Forwards

+
+
No active port forwards
+
+
+
+ + + +` + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, html) +} \ No newline at end of file diff --git a/packages/cli/scripts/download-scrcpy-server.sh b/packages/cli/scripts/download-scrcpy-server.sh new file mode 100755 index 00000000..3e0a87a5 --- /dev/null +++ b/packages/cli/scripts/download-scrcpy-server.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Download scrcpy server jar file +# Usage: ./scripts/download-scrcpy-server.sh [version] + +set -e + +VERSION=${1:-"v3.3.1"} +SERVER_URL="https://github.com/Genymobile/scrcpy/releases/download/${VERSION}/scrcpy-server-${VERSION}" +ASSETS_DIR="assets" +OUTPUT_FILE="${ASSETS_DIR}/scrcpy-server.jar" + +# Create assets directory if it doesn't exist +mkdir -p "${ASSETS_DIR}" + +echo "Downloading scrcpy-server ${VERSION}..." + +# Check if wget or curl is available +if command -v wget >/dev/null 2>&1; then + wget -O "${OUTPUT_FILE}" "${SERVER_URL}" +elif command -v curl >/dev/null 2>&1; then + curl -L -o "${OUTPUT_FILE}" "${SERVER_URL}" +else + echo "Error: Neither wget nor curl is available" + exit 1 +fi + +# Verify download +if [ -f "${OUTPUT_FILE}" ]; then + echo "Successfully downloaded ${OUTPUT_FILE}" + echo "File size: $(du -h ${OUTPUT_FILE} | cut -f1)" +else + echo "Error: Failed to download ${OUTPUT_FILE}" + exit 1 +fi \ No newline at end of file diff --git a/packages/live-view/.gitignore b/packages/live-view/.gitignore new file mode 100644 index 00000000..f11766d2 --- /dev/null +++ b/packages/live-view/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Production +dist/ +static/ +build/ + +# Testing +coverage/ + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# TypeScript +*.tsbuildinfo + +# Cache +.eslintcache +.turbo \ No newline at end of file diff --git a/packages/live-view/Makefile b/packages/live-view/Makefile new file mode 100644 index 00000000..7ee8d654 --- /dev/null +++ b/packages/live-view/Makefile @@ -0,0 +1,39 @@ +.PHONY: build force-build clean dev install + +# Default target +all: build + +# Install dependencies +install: + @echo "Installing dependencies..." + @npm install + +# Build static files (checks if rebuild is needed automatically) +build: + @./scripts/build.sh + +# Force rebuild (ignore timestamp checks) +force-build: + @echo "🔨 Force rebuilding live-view static files..." + @[ -d "node_modules" ] || npm install + @npm run build:static && echo "✅ Live-view static files rebuilt successfully" || (echo "❌ Failed to rebuild live-view static files"; exit 1) + +# Development mode with hot reload +dev: + @echo "Starting development server..." + @npm run dev + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + @rm -rf $(BUILD_DIR) + @rm -rf dist + +# Help +help: + @echo "Available targets:" + @echo " install - Install npm dependencies" + @echo " build - Build static files (only if source changed)" + @echo " dev - Start development server with hot reload" + @echo " clean - Remove build artifacts" + @echo " help - Show this help message" \ No newline at end of file diff --git a/packages/live-view/README.md b/packages/live-view/README.md new file mode 100644 index 00000000..12323cbe --- /dev/null +++ b/packages/live-view/README.md @@ -0,0 +1,92 @@ +# @gbox/live-view + +Live view component for Android device streaming using WebRTC. + +## Features + +- Real-time Android screen mirroring +- WebRTC-based low-latency streaming +- Touch and control input support +- Android system button controls +- Device list management +- Auto-reconnection support + +## Installation + +```bash +npm install @gbox/live-view +# or +pnpm add @gbox/live-view +``` + +## Usage + +### As a React Component + +```tsx +import { AndroidLiveView } from '@gbox/live-view'; + +function App() { + return ( + console.log('Connected to', device)} + onDisconnect={() => console.log('Disconnected')} + onError={(error) => console.error('Error:', error)} + /> + ); +} +``` + +### Props + +- `apiUrl`: API endpoint URL (default: `/api`) +- `wsUrl`: WebSocket URL for WebRTC signaling (default: `ws://localhost:8080/ws`) +- `deviceSerial`: Auto-connect to specific device +- `autoConnect`: Auto-connect when device is available +- `showControls`: Show video controls and stats +- `showDeviceList`: Show device list sidebar +- `showAndroidControls`: Show Android control buttons +- `onConnect`: Callback when device connects +- `onDisconnect`: Callback when device disconnects +- `onError`: Error handler callback +- `className`: Additional CSS class name + +## Development + +```bash +# Install dependencies +pnpm install + +# Run development server +pnpm dev + +# Build component library +pnpm build:component + +# Build static site +pnpm build:static + +# Build both +pnpm build +``` + +## Publishing + +This package is configured to publish to GitHub Packages registry. + +```bash +# Login to GitHub registry +npm login --registry=https://npm.pkg.github.com + +# Publish +npm publish +``` + +## License + +Apache-2.0 \ No newline at end of file diff --git a/packages/live-view/index.html b/packages/live-view/index.html new file mode 100644 index 00000000..e6e057c6 --- /dev/null +++ b/packages/live-view/index.html @@ -0,0 +1,30 @@ + + + + + + GBOX Live View + + + +
+ + + \ No newline at end of file diff --git a/packages/live-view/package.json b/packages/live-view/package.json new file mode 100644 index 00000000..ac76bd5e --- /dev/null +++ b/packages/live-view/package.json @@ -0,0 +1,65 @@ +{ + "name": "@gbox/live-view", + "version": "0.1.0", + "description": "Live view component for Android device streaming", + "main": "dist/index.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "static" + ], + "scripts": { + "dev": "vite", + "build": "npm run build:component && npm run build:static", + "build:component": "rollup -c", + "build:static": "vite build", + "preview": "vite preview", + "type-check": "tsc --noEmit", + "lint": "eslint src", + "prepublishOnly": "npm run build" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.5", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.55.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "rollup": "^4.9.1", + "rollup-plugin-dts": "^6.1.0", + "rollup-plugin-peer-deps-external": "^2.2.4", + "rollup-plugin-postcss": "^4.0.2", + "typescript": "^5.3.3", + "vite": "^5.0.8" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/gbox-ai/gbox.git", + "directory": "packages/live-view" + }, + "keywords": [ + "android", + "scrcpy", + "webrtc", + "streaming", + "live-view", + "react" + ], + "license": "Apache-2.0", + "packageManager": "pnpm@10.13.1+sha256.0f9ed48d808996ae007835fb5c4641cf9a300def2eddc9e957d9bbe4768c5f28" +} diff --git a/packages/live-view/pnpm-lock.yaml b/packages/live-view/pnpm-lock.yaml new file mode 100644 index 00000000..5b7eb115 --- /dev/null +++ b/packages/live-view/pnpm-lock.yaml @@ -0,0 +1,4267 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@rollup/plugin-commonjs': + specifier: ^25.0.7 + version: 25.0.8(rollup@4.50.1) + '@rollup/plugin-node-resolve': + specifier: ^15.2.3 + version: 15.3.1(rollup@4.50.1) + '@rollup/plugin-typescript': + specifier: ^11.1.5 + version: 11.1.6(rollup@4.50.1)(typescript@5.9.2) + '@types/react': + specifier: ^18.2.43 + version: 18.3.24 + '@types/react-dom': + specifier: ^18.2.17 + version: 18.3.7(@types/react@18.3.24) + '@typescript-eslint/eslint-plugin': + specifier: ^6.14.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^6.14.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.2) + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.7.0(vite@5.4.20) + eslint: + specifier: ^8.55.0 + version: 8.57.1 + eslint-plugin-react: + specifier: ^7.33.2 + version: 7.37.5(eslint@8.57.1) + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.2(eslint@8.57.1) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + rollup: + specifier: ^4.9.1 + version: 4.50.1 + rollup-plugin-dts: + specifier: ^6.1.0 + version: 6.2.3(rollup@4.50.1)(typescript@5.9.2) + rollup-plugin-peer-deps-external: + specifier: ^2.2.4 + version: 2.2.4(rollup@4.50.1) + rollup-plugin-postcss: + specifier: ^4.0.2 + version: 4.0.2(postcss@8.5.6) + typescript: + specifier: ^5.3.3 + version: 5.9.2 + vite: + specifier: ^5.0.8 + version: 5.4.20 + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.4': + resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.4': + resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.3': + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.4': + resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.4': + resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.30': + resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/plugin-commonjs@25.0.8': + resolution: {integrity: sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-typescript@11.1.6': + resolution: {integrity: sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.50.1': + resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.50.1': + resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.50.1': + resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.50.1': + resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.50.1': + resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.50.1': + resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.50.1': + resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.50.1': + resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.50.1': + resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.50.1': + resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.50.1': + resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.50.1': + resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.50.1': + resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.50.1': + resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.50.1': + resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.50.1': + resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.50.1': + resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.50.1': + resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.50.1': + resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} + cpu: [x64] + os: [win32] + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.24': + resolution: {integrity: sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.25.4: + resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + + caniuse-lite@1.0.30001741: + resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-with-sourcemaps@1.1.0: + resolution: {integrity: sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-declaration-sorter@6.4.1: + resolution: {integrity: sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==} + engines: {node: ^10 || ^12 || >=14} + peerDependencies: + postcss: ^8.0.9 + + css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssnano-preset-default@5.2.14: + resolution: {integrity: sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + cssnano-utils@3.1.0: + resolution: {integrity: sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + cssnano@5.1.15: + resolution: {integrity: sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + csso@4.2.0: + resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==} + engines: {node: '>=8.0.0'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.214: + resolution: {integrity: sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q==} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.2.1: + resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@4.6.2: + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generic-names@4.0.0: + resolution: {integrity: sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + icss-replace-symbols@1.1.0: + resolution: {integrity: sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==} + + icss-utils@5.1.0: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-cwd@3.0.0: + resolution: {integrity: sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==} + engines: {node: '>=8'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-from@3.0.0: + resolution: {integrity: sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==} + engines: {node: '>=8'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.0: + resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + loader-utils@3.3.1: + resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} + engines: {node: '>= 12.13.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.20: + resolution: {integrity: sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==} + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pify@5.0.0: + resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} + engines: {node: '>=10'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-calc@8.2.4: + resolution: {integrity: sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==} + peerDependencies: + postcss: ^8.2.2 + + postcss-colormin@5.3.1: + resolution: {integrity: sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-convert-values@5.1.3: + resolution: {integrity: sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-discard-comments@5.1.2: + resolution: {integrity: sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-discard-duplicates@5.1.0: + resolution: {integrity: sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-discard-empty@5.1.1: + resolution: {integrity: sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-discard-overridden@5.1.0: + resolution: {integrity: sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-merge-longhand@5.1.7: + resolution: {integrity: sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-merge-rules@5.1.4: + resolution: {integrity: sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-minify-font-values@5.1.0: + resolution: {integrity: sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-minify-gradients@5.1.1: + resolution: {integrity: sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-minify-params@5.1.4: + resolution: {integrity: sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-minify-selectors@5.2.1: + resolution: {integrity: sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-modules-extract-imports@3.1.0: + resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-local-by-default@4.2.0: + resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-scope@3.2.1: + resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-values@4.0.0: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules@4.3.1: + resolution: {integrity: sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==} + peerDependencies: + postcss: ^8.0.0 + + postcss-normalize-charset@5.1.0: + resolution: {integrity: sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-display-values@5.1.0: + resolution: {integrity: sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-positions@5.1.1: + resolution: {integrity: sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-repeat-style@5.1.1: + resolution: {integrity: sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-string@5.1.0: + resolution: {integrity: sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-timing-functions@5.1.0: + resolution: {integrity: sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-unicode@5.1.1: + resolution: {integrity: sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-url@5.1.0: + resolution: {integrity: sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-whitespace@5.1.1: + resolution: {integrity: sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-ordered-values@5.1.3: + resolution: {integrity: sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-reduce-initial@5.1.2: + resolution: {integrity: sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-reduce-transforms@5.1.0: + resolution: {integrity: sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.0: + resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} + engines: {node: '>=4'} + + postcss-svgo@5.1.0: + resolution: {integrity: sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-unique-selectors@5.1.1: + resolution: {integrity: sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + promise.series@0.2.0: + resolution: {integrity: sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==} + engines: {node: '>=0.12'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup-plugin-dts@6.2.3: + resolution: {integrity: sha512-UgnEsfciXSPpASuOelix7m4DrmyQgiaWBnvI0TM4GxuDh5FkqW8E5hu57bCxXB90VvR1WNfLV80yEDN18UogSA==} + engines: {node: '>=16'} + peerDependencies: + rollup: ^3.29.4 || ^4 + typescript: ^4.5 || ^5.0 + + rollup-plugin-peer-deps-external@2.2.4: + resolution: {integrity: sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g==} + peerDependencies: + rollup: '*' + + rollup-plugin-postcss@4.0.2: + resolution: {integrity: sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==} + engines: {node: '>=10'} + peerDependencies: + postcss: 8.x + + rollup-pluginutils@2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + + rollup@4.50.1: + resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-identifier@0.4.2: + resolution: {integrity: sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stable@0.1.8: + resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} + deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string-hash@1.1.3: + resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + style-inject@0.3.0: + resolution: {integrity: sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==} + + stylehacks@5.1.1: + resolution: {integrity: sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svgo@2.8.0: + resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==} + engines: {node: '>=10.13.0'} + hasBin: true + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@5.4.20: + resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.4': {} + + '@babel/core@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.4 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.1 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.3': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.4 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.25.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.28.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + + '@babel/parser@7.28.4': + dependencies: + '@babel/types': 7.28.4 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + + '@babel/traverse@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.4 + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.4': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.1 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.1 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.30 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.30': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/plugin-commonjs@25.0.8(rollup@4.50.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.50.1) + commondir: 1.0.1 + estree-walker: 2.0.2 + glob: 8.1.0 + is-reference: 1.2.1 + magic-string: 0.30.19 + optionalDependencies: + rollup: 4.50.1 + + '@rollup/plugin-node-resolve@15.3.1(rollup@4.50.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.50.1) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.10 + optionalDependencies: + rollup: 4.50.1 + + '@rollup/plugin-typescript@11.1.6(rollup@4.50.1)(typescript@5.9.2)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.50.1) + resolve: 1.22.10 + typescript: 5.9.2 + optionalDependencies: + rollup: 4.50.1 + + '@rollup/pluginutils@5.3.0(rollup@4.50.1)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.50.1 + + '@rollup/rollup-android-arm-eabi@4.50.1': + optional: true + + '@rollup/rollup-android-arm64@4.50.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.50.1': + optional: true + + '@rollup/rollup-darwin-x64@4.50.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.50.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.50.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.50.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.50.1': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.50.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.50.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.50.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.50.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.50.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.50.1': + optional: true + + '@trysound/sax@0.2.0': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.4 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.4 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.24)': + dependencies: + '@types/react': 18.3.24 + + '@types/react@18.3.24': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.1.3 + + '@types/resolve@1.20.2': {} + + '@types/semver@7.7.1': {} + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.2) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.1 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + semver: 7.7.2 + ts-api-utils: 1.4.3(typescript@5.9.2) + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.1 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.2)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) + debug: 4.4.1 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.2) + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.2)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.1 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.7.2 + ts-api-utils: 1.4.3(typescript@5.9.2) + optionalDependencies: + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.2)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.1 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) + eslint: 8.57.1 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-react@4.7.0(vite@5.4.20)': + dependencies: + '@babel/core': 7.28.4 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.20 + transitivePeerDependencies: + - supports-color + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array-union@2.1.0: {} + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + balanced-match@1.0.2: {} + + boolbase@1.0.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.25.4: + dependencies: + caniuse-lite: 1.0.30001741 + electron-to-chromium: 1.5.214 + node-releases: 2.0.20 + update-browserslist-db: 1.1.3(browserslist@4.25.4) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-api@3.0.0: + dependencies: + browserslist: 4.25.4 + caniuse-lite: 1.0.30001741 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + + caniuse-lite@1.0.30001741: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + commander@7.2.0: {} + + commondir@1.0.1: {} + + concat-map@0.0.1: {} + + concat-with-sourcemaps@1.1.0: + dependencies: + source-map: 0.6.1 + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-declaration-sorter@6.4.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + css-select@4.3.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + cssnano-preset-default@5.2.14(postcss@8.5.6): + dependencies: + css-declaration-sorter: 6.4.1(postcss@8.5.6) + cssnano-utils: 3.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-calc: 8.2.4(postcss@8.5.6) + postcss-colormin: 5.3.1(postcss@8.5.6) + postcss-convert-values: 5.1.3(postcss@8.5.6) + postcss-discard-comments: 5.1.2(postcss@8.5.6) + postcss-discard-duplicates: 5.1.0(postcss@8.5.6) + postcss-discard-empty: 5.1.1(postcss@8.5.6) + postcss-discard-overridden: 5.1.0(postcss@8.5.6) + postcss-merge-longhand: 5.1.7(postcss@8.5.6) + postcss-merge-rules: 5.1.4(postcss@8.5.6) + postcss-minify-font-values: 5.1.0(postcss@8.5.6) + postcss-minify-gradients: 5.1.1(postcss@8.5.6) + postcss-minify-params: 5.1.4(postcss@8.5.6) + postcss-minify-selectors: 5.2.1(postcss@8.5.6) + postcss-normalize-charset: 5.1.0(postcss@8.5.6) + postcss-normalize-display-values: 5.1.0(postcss@8.5.6) + postcss-normalize-positions: 5.1.1(postcss@8.5.6) + postcss-normalize-repeat-style: 5.1.1(postcss@8.5.6) + postcss-normalize-string: 5.1.0(postcss@8.5.6) + postcss-normalize-timing-functions: 5.1.0(postcss@8.5.6) + postcss-normalize-unicode: 5.1.1(postcss@8.5.6) + postcss-normalize-url: 5.1.0(postcss@8.5.6) + postcss-normalize-whitespace: 5.1.1(postcss@8.5.6) + postcss-ordered-values: 5.1.3(postcss@8.5.6) + postcss-reduce-initial: 5.1.2(postcss@8.5.6) + postcss-reduce-transforms: 5.1.0(postcss@8.5.6) + postcss-svgo: 5.1.0(postcss@8.5.6) + postcss-unique-selectors: 5.1.1(postcss@8.5.6) + + cssnano-utils@3.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + cssnano@5.1.15(postcss@8.5.6): + dependencies: + cssnano-preset-default: 5.2.14(postcss@8.5.6) + lilconfig: 2.1.0 + postcss: 8.5.6 + yaml: 1.10.2 + + csso@4.2.0: + dependencies: + css-tree: 1.1.3 + + csstype@3.1.3: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + domelementtype@2.3.0: {} + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.214: {} + + entities@2.2.0: {} + + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-react@7.37.5(eslint@8.57.1): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.1 + eslint: 8.57.1 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.1 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@0.6.1: {} + + estree-walker@2.0.2: {} + + esutils@2.0.3: {} + + eventemitter3@4.0.7: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generic-names@4.0.0: + dependencies: + loader-utils: 3.3.1 + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graphemer@1.4.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + icss-replace-symbols@1.1.0: {} + + icss-utils@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + ignore@5.3.2: {} + + import-cwd@3.0.0: + dependencies: + import-from: 3.0.0 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-from@3.0.0: + dependencies: + resolve-from: 5.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.0: + dependencies: + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-module@1.0.0: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.8 + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@2.1.0: {} + + loader-utils@3.3.1: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.uniq@4.5.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.14: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.20: {} + + normalize-url@6.1.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-finally@1.0.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pify@5.0.0: {} + + possible-typed-array-names@1.1.0: {} + + postcss-calc@8.2.4(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + postcss-value-parser: 4.2.0 + + postcss-colormin@5.3.1(postcss@8.5.6): + dependencies: + browserslist: 4.25.4 + caniuse-api: 3.0.0 + colord: 2.9.3 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-convert-values@5.1.3(postcss@8.5.6): + dependencies: + browserslist: 4.25.4 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-discard-comments@5.1.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-duplicates@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-empty@5.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-overridden@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-load-config@3.1.4(postcss@8.5.6): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.5.6 + + postcss-merge-longhand@5.1.7(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + stylehacks: 5.1.1(postcss@8.5.6) + + postcss-merge-rules@5.1.4(postcss@8.5.6): + dependencies: + browserslist: 4.25.4 + caniuse-api: 3.0.0 + cssnano-utils: 3.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-minify-font-values@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-gradients@5.1.1(postcss@8.5.6): + dependencies: + colord: 2.9.3 + cssnano-utils: 3.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-params@5.1.4(postcss@8.5.6): + dependencies: + browserslist: 4.25.4 + cssnano-utils: 3.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-selectors@5.2.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-modules-extract-imports@3.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-modules-local-by-default@4.2.0(postcss@8.5.6): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-selector-parser: 7.1.0 + postcss-value-parser: 4.2.0 + + postcss-modules-scope@3.2.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.0 + + postcss-modules-values@4.0.0(postcss@8.5.6): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + + postcss-modules@4.3.1(postcss@8.5.6): + dependencies: + generic-names: 4.0.0 + icss-replace-symbols: 1.1.0 + lodash.camelcase: 4.3.0 + postcss: 8.5.6 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) + postcss-modules-scope: 3.2.1(postcss@8.5.6) + postcss-modules-values: 4.0.0(postcss@8.5.6) + string-hash: 1.1.3 + + postcss-normalize-charset@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-normalize-display-values@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-positions@5.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-repeat-style@5.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-string@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-timing-functions@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-unicode@5.1.1(postcss@8.5.6): + dependencies: + browserslist: 4.25.4 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-url@5.1.0(postcss@8.5.6): + dependencies: + normalize-url: 6.1.0 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-whitespace@5.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-ordered-values@5.1.3(postcss@8.5.6): + dependencies: + cssnano-utils: 3.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-reduce-initial@5.1.2(postcss@8.5.6): + dependencies: + browserslist: 4.25.4 + caniuse-api: 3.0.0 + postcss: 8.5.6 + + postcss-reduce-transforms@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.0: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-svgo@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + svgo: 2.8.0 + + postcss-unique-selectors@5.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + promise.series@0.2.0: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@16.13.1: {} + + react-refresh@0.17.0: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup-plugin-dts@6.2.3(rollup@4.50.1)(typescript@5.9.2): + dependencies: + magic-string: 0.30.19 + rollup: 4.50.1 + typescript: 5.9.2 + optionalDependencies: + '@babel/code-frame': 7.27.1 + + rollup-plugin-peer-deps-external@2.2.4(rollup@4.50.1): + dependencies: + rollup: 4.50.1 + + rollup-plugin-postcss@4.0.2(postcss@8.5.6): + dependencies: + chalk: 4.1.2 + concat-with-sourcemaps: 1.1.0 + cssnano: 5.1.15(postcss@8.5.6) + import-cwd: 3.0.0 + p-queue: 6.6.2 + pify: 5.0.0 + postcss: 8.5.6 + postcss-load-config: 3.1.4(postcss@8.5.6) + postcss-modules: 4.3.1(postcss@8.5.6) + promise.series: 0.2.0 + resolve: 1.22.10 + rollup-pluginutils: 2.8.2 + safe-identifier: 0.4.2 + style-inject: 0.3.0 + transitivePeerDependencies: + - ts-node + + rollup-pluginutils@2.8.2: + dependencies: + estree-walker: 0.6.1 + + rollup@4.50.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.50.1 + '@rollup/rollup-android-arm64': 4.50.1 + '@rollup/rollup-darwin-arm64': 4.50.1 + '@rollup/rollup-darwin-x64': 4.50.1 + '@rollup/rollup-freebsd-arm64': 4.50.1 + '@rollup/rollup-freebsd-x64': 4.50.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.50.1 + '@rollup/rollup-linux-arm-musleabihf': 4.50.1 + '@rollup/rollup-linux-arm64-gnu': 4.50.1 + '@rollup/rollup-linux-arm64-musl': 4.50.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.50.1 + '@rollup/rollup-linux-ppc64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-musl': 4.50.1 + '@rollup/rollup-linux-s390x-gnu': 4.50.1 + '@rollup/rollup-linux-x64-gnu': 4.50.1 + '@rollup/rollup-linux-x64-musl': 4.50.1 + '@rollup/rollup-openharmony-arm64': 4.50.1 + '@rollup/rollup-win32-arm64-msvc': 4.50.1 + '@rollup/rollup-win32-ia32-msvc': 4.50.1 + '@rollup/rollup-win32-x64-msvc': 4.50.1 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-identifier@0.4.2: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + semver@7.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + stable@0.1.8: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string-hash@1.1.3: {} + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.0 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + style-inject@0.3.0: {} + + stylehacks@5.1.1(postcss@8.5.6): + dependencies: + browserslist: 4.25.4 + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svgo@2.8.0: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 4.3.0 + css-tree: 1.1.3 + csso: 4.2.0 + picocolors: 1.1.1 + stable: 0.1.8 + + text-table@0.2.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@1.4.3(typescript@5.9.2): + dependencies: + typescript: 5.9.2 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript@5.9.2: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + update-browserslist-db@1.1.3(browserslist@4.25.4): + dependencies: + browserslist: 4.25.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite@5.4.20: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.50.1 + optionalDependencies: + fsevents: 2.3.3 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.0 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + yallist@3.1.1: {} + + yaml@1.10.2: {} + + yocto-queue@0.1.0: {} diff --git a/packages/live-view/rollup.config.js b/packages/live-view/rollup.config.js new file mode 100644 index 00000000..289661d3 --- /dev/null +++ b/packages/live-view/rollup.config.js @@ -0,0 +1,46 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import typescript from '@rollup/plugin-typescript'; +import peerDepsExternal from 'rollup-plugin-peer-deps-external'; +import postcss from 'rollup-plugin-postcss'; +import dts from 'rollup-plugin-dts'; + +export default [ + { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.js', + format: 'cjs', + sourcemap: true, + }, + { + file: 'dist/index.esm.js', + format: 'esm', + sourcemap: true, + }, + ], + plugins: [ + peerDepsExternal(), + resolve(), + commonjs(), + typescript({ + tsconfig: './tsconfig.json', + declaration: false, + }), + postcss({ + modules: true, + extract: false, + inject: true, + minimize: true, + }), + ], + external: ['react', 'react-dom'], + }, + { + input: 'dist/index.d.ts', + output: [{ file: 'dist/index.d.ts', format: 'es' }], + plugins: [dts()], + external: [/\.css$/], + }, +]; \ No newline at end of file diff --git a/packages/live-view/scripts/build.sh b/packages/live-view/scripts/build.sh new file mode 100755 index 00000000..383d14af --- /dev/null +++ b/packages/live-view/scripts/build.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Build live-view static files if needed + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +# Check if rebuild is needed +NEED_BUILD=$("$SCRIPT_DIR/check-rebuild.sh") + +if [ "$NEED_BUILD" = "1" ]; then + echo "🔨 Building live-view static files (source files changed)..." + + # Install dependencies if needed + if [ ! -d "node_modules" ]; then + echo "Installing dependencies..." + npm install + fi + + # Build static files + npm run build:static + + if [ $? -eq 0 ]; then + echo "✅ Live-view static files built successfully" + else + echo "❌ Failed to build live-view static files" + exit 1 + fi +else + echo "✓ Live-view static files are up to date" +fi \ No newline at end of file diff --git a/packages/live-view/scripts/check-rebuild.sh b/packages/live-view/scripts/check-rebuild.sh new file mode 100755 index 00000000..8292b200 --- /dev/null +++ b/packages/live-view/scripts/check-rebuild.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Check if live-view static files need to be rebuilt +# Returns 0 if rebuild is needed, 1 if files are up to date + +BUILD_DIR="static" +SRC_DIR="src" + +# Check if build directory exists +if [ ! -d "$BUILD_DIR" ] || [ ! -d "$BUILD_DIR/assets" ]; then + echo "1" # Need rebuild + exit 0 +fi + +# Get the newest built JS file timestamp +NEWEST_BUILD=$(find "$BUILD_DIR/assets" -name "*.js" -type f -exec stat -f "%m" {} \; 2>/dev/null | sort -rn | head -1) + +if [ -z "$NEWEST_BUILD" ]; then + echo "1" # Need rebuild + exit 0 +fi + +# Check source files +for src in $(find "$SRC_DIR" -name "*.tsx" -o -name "*.ts" -o -name "*.css" -o -name "*.module.css" 2>/dev/null); do + SRC_TIME=$(stat -f "%m" "$src" 2>/dev/null) + if [ "$SRC_TIME" -gt "$NEWEST_BUILD" ]; then + echo "1" # Need rebuild + exit 0 + fi +done + +# Check config files +for config in index.html vite.config.ts tsconfig.json package.json; do + if [ -f "$config" ]; then + CONFIG_TIME=$(stat -f "%m" "$config" 2>/dev/null) + if [ "$CONFIG_TIME" -gt "$NEWEST_BUILD" ]; then + echo "1" # Need rebuild + exit 0 + fi + fi +done + +echo "0" # No rebuild needed \ No newline at end of file diff --git a/packages/live-view/src/components/AndroidLiveView.module.css b/packages/live-view/src/components/AndroidLiveView.module.css new file mode 100644 index 00000000..7796f71e --- /dev/null +++ b/packages/live-view/src/components/AndroidLiveView.module.css @@ -0,0 +1,99 @@ +.container { + display: flex; + height: 100vh; + background: #1a1a1a; + color: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.sidebar { + width: 300px; + background: #2d2d2d; + padding: 20px; + overflow-y: auto; +} + +.mainContent { + flex: 1; + display: flex; + flex-direction: column; + background: #000; +} + +.videoContainer { + flex: 1; + position: relative; + background: #000; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + min-height: 0; + padding: 20px; +} + +.videoWrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + max-width: calc(100% - 40px); + max-height: calc(100% - 40px); +} + +.video { + object-fit: contain; + display: block; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +.video.dragging { + cursor: grabbing; +} + +.touchIndicator { + position: fixed; + width: 20px; + height: 20px; + background: rgba(0, 122, 255, 0.4); + border-radius: 50%; + pointer-events: none; + z-index: 1001; + display: none; + transform: translate(-50%, -50%); + box-shadow: 0 0 10px rgba(0, 122, 255, 0.5); + /* Remove transition for instant response */ +} + +.touchIndicator.active { + display: block; +} + +.touchIndicator.dragging { + background: rgba(0, 122, 255, 0.6); + box-shadow: 0 0 20px rgba(0, 122, 255, 0.8); + width: 24px; + height: 24px; +} + +.stats { + position: absolute; + bottom: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.7); + padding: 8px 12px; + border-radius: 5px; + font-size: 12px; + color: #ccc; + z-index: 10; +} \ No newline at end of file diff --git a/packages/live-view/src/components/AndroidLiveView.tsx b/packages/live-view/src/components/AndroidLiveView.tsx new file mode 100644 index 00000000..c12258ec --- /dev/null +++ b/packages/live-view/src/components/AndroidLiveView.tsx @@ -0,0 +1,249 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { AndroidLiveViewProps, Stats } from '../types'; +import { WebRTCClient } from '../lib/webrtc-client'; +import { DeviceList } from './DeviceList'; +import { ControlButtons } from './ControlButtons'; +import { + useKeyboardHandler, + useClipboardHandler, + useMouseHandler, + useClickHandler, + useWheelHandler, + useDeviceManager, + useControlHandler, +} from '../hooks'; +import styles from './AndroidLiveView.module.css'; + +export const AndroidLiveView: React.FC = ({ + apiUrl = '/api', + wsUrl = 'ws://localhost:8080/ws', + deviceSerial, + autoConnect = false, + showControls = true, + showDeviceList = true, + showAndroidControls = true, + onConnect, + onDisconnect, + onError, + className, +}) => { + const videoRef = useRef(null); + const clientRef = useRef(null); + const touchIndicatorRef = useRef(null); + const [connectionStatus, setConnectionStatus] = useState(''); + const [isConnected, setIsConnected] = useState(false); + const [stats, setStats] = useState({ fps: 0, resolution: '', latency: 0 }); + const [keyboardCaptureEnabled] = useState(true); + + // Use custom hooks for different functionalities + const { devices, currentDevice, loading, setCurrentDevice, loadDevices } = useDeviceManager({ + apiUrl, + showDeviceList, + autoConnect, + deviceSerial, + isConnected, + onError, + }); + + const { handleSmartPaste, handleSmartCopy } = useClipboardHandler({ + clientRef, + isConnected, + keyboardCaptureEnabled, + }); + + const { handleKeyDown, handleKeyUp } = useKeyboardHandler({ + clientRef, + isConnected, + keyboardCaptureEnabled, + onSmartPaste: handleSmartPaste, + onSmartCopy: handleSmartCopy, + }); + + const { isDragging, touchPosition, handleMouseInteraction, handleTouchInteraction, handleMouseLeave } = useMouseHandler({ + clientRef, + }); + + const { handleClick } = useClickHandler({ + clientRef, + isConnected, + }); + + const { handleControlAction, handleIMESwitch } = useControlHandler({ + clientRef, + isConnected, + }); + + // Initialize wheel handler + useWheelHandler({ + videoRef, + clientRef, + isConnected, + }); + + // Handle window resize for keyframe requests + useEffect(() => { + const handleResize = () => { + if (clientRef.current && isConnected) { + console.log('[WebRTC] Window resized, requesting keyframe'); + clientRef.current.requestKeyframe(); + } + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [isConnected]); + + + // Initialize WebRTC client + useEffect(() => { + if (!videoRef.current) return; + + // Auto-focus video element for keyboard input + videoRef.current.focus(); + + clientRef.current = new WebRTCClient(videoRef.current, { + onConnectionStateChange: (state, message) => { + setConnectionStatus(message || ''); + setIsConnected(state === 'connected'); + + if (state === 'connected' && currentDevice) { + const device = devices.find(d => d.serial === currentDevice); + if (device) onConnect?.(device); + // Auto-focus video element when connected for keyboard input + if (videoRef.current) { + videoRef.current.focus(); + console.log('[Keyboard] Video element auto-focused after connection'); + } + } else if (state === 'disconnected') { + onDisconnect?.(); + } + }, + onError: (error) => { + console.error('WebRTC error:', error); + onError?.(error); + }, + onStatsUpdate: (newStats) => { + setStats(prev => ({ ...prev, ...newStats })); + }, + }); + + return () => { + clientRef.current?.cleanup(); + }; + }, []); + + + // Auto-connect to specified device + useEffect(() => { + if (autoConnect && deviceSerial && !isConnected && clientRef.current) { + handleConnect(deviceSerial); + } + }, [autoConnect, deviceSerial]); + + const handleConnect = async (serial: string) => { + if (!clientRef.current) return; + + try { + // Directly connect via WebSocket (no need for API pre-connection) + setCurrentDevice(serial); + await clientRef.current.connect(serial, wsUrl); + } catch (error) { + console.error('Connection failed:', error); + onError?.(error as Error); + } + }; + + const handleDisconnect = async () => { + if (!clientRef.current || !currentDevice) return; + + try { + await clientRef.current.disconnect(); + setCurrentDevice(null); + } catch (error) { + console.error('Disconnect failed:', error); + } + }; + + + + + + + + + + return ( +
+ {showDeviceList && ( +
+ +
+ )} + +
+
+
+
+ +
+ + {showControls && ( +
+
Resolution: {stats.resolution || '-'}
+
FPS: {stats.fps || '-'}
+
Latency: {stats.latency ? `${stats.latency}ms` : '-'}
+
+ )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/packages/live-view/src/components/ControlButtons.module.css b/packages/live-view/src/components/ControlButtons.module.css new file mode 100644 index 00000000..a98da781 --- /dev/null +++ b/packages/live-view/src/components/ControlButtons.module.css @@ -0,0 +1,72 @@ +.controlButtons { + position: fixed; + right: 0; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 8px; + background: rgba(0, 0, 0, 0.7); + padding: 8px 0 8px 12px; + border-radius: 8px 0 0 8px; + z-index: 1000; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + margin: 0; + box-sizing: border-box; +} + +.controlBtn { + width: 40px; + height: 40px; + border: none; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + padding: 0; + margin: 0; + color: white; + position: relative; + box-sizing: border-box; + outline: none; +} + +.controlBtn:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.1); + color: #4CAF50; +} + +.controlBtn:active { + background: rgba(255, 255, 255, 0.3); + transform: scale(0.95); +} + +.controlBtn svg { + width: 24px; + height: 24px; + display: block; + margin: 0; + padding: 0; +} + +.separator { + width: 40px; + height: 1px; + background: rgba(255, 255, 255, 0.2); + cursor: default; + border-radius: 0; +} + +.controlBtn.active { + background: rgba(76, 175, 80, 0.3); + border: 2px solid #4CAF50; +} + +.controlBtn.active:hover { + background: rgba(76, 175, 80, 0.4); + border-color: #45a049; +} \ No newline at end of file diff --git a/packages/live-view/src/components/ControlButtons.tsx b/packages/live-view/src/components/ControlButtons.tsx new file mode 100644 index 00000000..7de3f7c3 --- /dev/null +++ b/packages/live-view/src/components/ControlButtons.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import styles from './ControlButtons.module.css'; + +interface ControlButtonsProps { + onAction: (action: string) => void; + onIMESwitch?: () => void; +} + +export const ControlButtons: React.FC = ({ onAction, onIMESwitch }) => { + const buttons = [ + { id: 'power', title: 'Power', icon: PowerIcon }, + { id: 'volume_up', title: 'Volume Up', icon: VolumeUpIcon }, + { id: 'volume_down', title: 'Volume Down', icon: VolumeDownIcon }, + { id: 'separator', isSeparator: true }, + { id: 'back', title: 'Back', icon: BackIcon }, + { id: 'home', title: 'Home', icon: HomeIcon }, + { id: 'app_switch', title: 'Recent Apps', icon: RecentIcon }, + { id: 'separator2', isSeparator: true }, + { id: 'ime_switch', title: 'Switch Input Method', icon: IMESwitchIcon, isIMESwitch: true }, + ]; + + return ( +
+ {buttons.map((button) => { + if (button.isSeparator) { + return
; + } + + const Icon = button.icon; + const handleClick = () => { + if (button.isIMESwitch && onIMESwitch) { + onIMESwitch(); + } else { + onAction(button.id); + } + }; + + return ( + + ); + })} +
+ ); +}; + +// Icon components +const PowerIcon = () => ( + + + + +); + +const VolumeUpIcon = () => ( + + + + +); + +const VolumeDownIcon = () => ( + + + + +); + +const BackIcon = () => ( + + + + +); + +const HomeIcon = () => ( + + + + +); + +const RecentIcon = () => ( + + + + +); + +const IMESwitchIcon = () => ( + + + {/* Globe circle */} + + {/* Horizontal lines */} + + + + + + {/* Vertical line */} + + +); diff --git a/packages/live-view/src/components/DeviceList.module.css b/packages/live-view/src/components/DeviceList.module.css new file mode 100644 index 00000000..c8eadf97 --- /dev/null +++ b/packages/live-view/src/components/DeviceList.module.css @@ -0,0 +1,108 @@ +.deviceList { + margin-bottom: 20px; +} + +.deviceList h2 { + font-size: 18px; + margin-bottom: 15px; + color: #ffffff; +} + +.loading { + text-align: center; + padding: 20px; + color: #999; +} + +.spinner { + border: 3px solid #333; + border-top: 3px solid #007acc; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; + margin: 0 auto 10px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.empty { + text-align: center; + color: #999; + padding: 20px; +} + +.deviceItem { + padding: 10px; + margin: 5px 0; + background: #3d3d3d; + border-radius: 5px; + cursor: pointer; + transition: background 0.2s; + position: relative; + display: flex; + justify-content: space-between; + align-items: center; +} + +.deviceItem:hover { + background: #4d4d4d; +} + +.deviceItem.connected { + background: #2d5a2d; + cursor: default; +} + +.deviceInfo { + flex: 1; +} + +.deviceSerial { + font-weight: bold; + color: #ffffff; +} + +.deviceModel { + font-size: 0.9em; + color: #cccccc; +} + +.deviceState { + font-size: 0.8em; + color: #999999; +} + +.deviceState.connecting { + color: #9acd32; +} + +.deviceState.error { + color: #ff6b6b; +} + +.disconnectBtn { + padding: 5px 10px; + background: #cc0000; + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s; + margin-left: 10px; + opacity: 0.7; +} + +.deviceItem.connected:hover .disconnectBtn { + opacity: 1; + background: #ff0000; +} + +.disconnectBtn:hover { + background: #990000; + transform: scale(1.05); +} \ No newline at end of file diff --git a/packages/live-view/src/components/DeviceList.tsx b/packages/live-view/src/components/DeviceList.tsx new file mode 100644 index 00000000..c8e993ed --- /dev/null +++ b/packages/live-view/src/components/DeviceList.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { Device } from '../types'; +import styles from './DeviceList.module.css'; + +interface DeviceListProps { + devices: Device[]; + currentDevice: string | null; + connectionStatus: string; + isConnected: boolean; + loading: boolean; + onConnect: (serial: string) => void; + onDisconnect: () => void; + onRefresh: () => void; +} + +export const DeviceList: React.FC = ({ + devices, + currentDevice, + connectionStatus, + isConnected, + loading, + onConnect, + onDisconnect, + onRefresh, +}) => { + const getDeviceStatus = (device: Device): string => { + if (currentDevice === device.serial && connectionStatus) { + return connectionStatus; + } + if (device.connected || (currentDevice === device.serial && isConnected)) { + return device.videoWidth && device.videoHeight + ? `已连接 - ${device.videoWidth}x${device.videoHeight}` + : '已连接'; + } + return device.state; + }; + + const getStatusClass = (device: Device): string => { + if (currentDevice === device.serial && connectionStatus) { + if (connectionStatus.includes('正在连接') || + connectionStatus.includes('重连中') || + connectionStatus.includes('秒后重试')) { + return styles.connecting; + } else if (connectionStatus.includes('失败') || + connectionStatus.includes('断开')) { + return styles.error; + } + } + return ''; + }; + + return ( +
+

设备列表

+ + {loading && ( +
+
+ 正在加载设备... +
+ )} + + {!loading && devices.length === 0 && ( +
没有找到设备
+ )} + + {devices.map((device) => { + const isDeviceConnected = device.connected || + (currentDevice === device.serial && isConnected); + + return ( +
{ + if (!isDeviceConnected && device.state === 'device') { + onConnect(device.serial); + } + }} + > +
+
{device.serial}
+
{device.model || 'Unknown'}
+
+ {getDeviceStatus(device)} +
+
+ + {isDeviceConnected && ( + + )} +
+ ); + })} +
+ ); +}; \ No newline at end of file diff --git a/packages/live-view/src/components/LiveView.tsx b/packages/live-view/src/components/LiveView.tsx new file mode 100644 index 00000000..61650c55 --- /dev/null +++ b/packages/live-view/src/components/LiveView.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { AndroidLiveView } from './AndroidLiveView'; +import { LiveViewProps } from '../types'; + +/** + * Generic LiveView component that can be extended for different device types + */ +export const LiveView: React.FC = (props) => { + // For now, default to AndroidLiveView + // In the future, this could detect device type and render appropriate view + return ; +}; \ No newline at end of file diff --git a/packages/live-view/src/hooks/index.ts b/packages/live-view/src/hooks/index.ts new file mode 100644 index 00000000..72179029 --- /dev/null +++ b/packages/live-view/src/hooks/index.ts @@ -0,0 +1,7 @@ +export { useKeyboardHandler } from './useKeyboardHandler'; +export { useClipboardHandler } from './useClipboardHandler'; +export { useMouseHandler } from './useMouseHandler'; +export { useClickHandler } from './useClickHandler'; +export { useWheelHandler } from './useWheelHandler'; +export { useDeviceManager } from './useDeviceManager'; +export { useControlHandler } from './useControlHandler'; diff --git a/packages/live-view/src/hooks/useClickHandler.ts b/packages/live-view/src/hooks/useClickHandler.ts new file mode 100644 index 00000000..5e0322a2 --- /dev/null +++ b/packages/live-view/src/hooks/useClickHandler.ts @@ -0,0 +1,80 @@ +import { useCallback, useState } from 'react'; +import { WebRTCClient } from '../lib/webrtc-client'; + +interface UseClickHandlerProps { + clientRef: React.RefObject; + isConnected: boolean; +} + +export const useClickHandler = ({ clientRef, isConnected }: UseClickHandlerProps) => { + // Click detection for text selection + const [lastClickTime, setLastClickTime] = useState(0); + const [lastClickPosition, setLastClickPosition] = useState({ x: 0, y: 0 }); + const [clickCount, setClickCount] = useState(0); + + // Handle click for text selection (single, double, triple) + const handleClick = useCallback((e: React.MouseEvent) => { + if (!clientRef.current || !isConnected) return; + + const currentTime = Date.now(); + const currentPosition = { x: e.clientX, y: e.clientY }; + + // Check if this is a continuation of previous clicks (within 500ms and similar position) + const timeDiff = currentTime - lastClickTime; + const positionDiff = Math.sqrt( + Math.pow(currentPosition.x - lastClickPosition.x, 2) + + Math.pow(currentPosition.y - lastClickPosition.y, 2) + ); + + if (timeDiff < 500 && positionDiff < 50) { + // This is a continuation click + const newClickCount = clickCount + 1; + setClickCount(newClickCount); + + if (newClickCount === 2) { + // Double click - select word + console.log('[Click] Double click - select word'); + // Android double tap to select word (no special key combination needed) + // The system will handle word selection automatically + } else if (newClickCount === 3) { + // Triple click - select line/paragraph + console.log('[Click] Triple click - select line'); + // Send Ctrl+A for select all (closest to line selection) + const META_CTRL_ON = 0x1000; + clientRef.current.sendKeyEvent(113, 'down', META_CTRL_ON); + setTimeout(() => { + if (clientRef.current) { + clientRef.current.sendKeyEvent(29, 'down', META_CTRL_ON); + setTimeout(() => { + if (clientRef.current) { + clientRef.current.sendKeyEvent(29, 'up', META_CTRL_ON); + setTimeout(() => { + if (clientRef.current) { + clientRef.current.sendKeyEvent(113, 'up', 0); + } + }, 10); + } + }, 10); + } + }, 10); + + // Reset after triple click + setClickCount(0); + setLastClickTime(0); + setLastClickPosition({ x: 0, y: 0 }); + } + } else { + // New click sequence + setClickCount(1); + setLastClickTime(currentTime); + setLastClickPosition(currentPosition); + + // Single click - just position cursor (handled by normal touch event) + console.log('[Click] Single click - position cursor'); + } + }, [clientRef, isConnected, lastClickTime, lastClickPosition, clickCount]); + + return { + handleClick, + }; +}; diff --git a/packages/live-view/src/hooks/useClipboardHandler.ts b/packages/live-view/src/hooks/useClipboardHandler.ts new file mode 100644 index 00000000..aead79bc --- /dev/null +++ b/packages/live-view/src/hooks/useClipboardHandler.ts @@ -0,0 +1,100 @@ +import { useCallback } from 'react'; +import { WebRTCClient } from '../lib/webrtc-client'; + +interface UseClipboardHandlerProps { + clientRef: React.RefObject; + isConnected: boolean; + keyboardCaptureEnabled: boolean; +} + +export const useClipboardHandler = ({ + clientRef, + isConnected, + keyboardCaptureEnabled, +}: UseClipboardHandlerProps) => { + // Smart clipboard sync - paste to device + const handleSmartPaste = useCallback(async () => { + console.log('[Clipboard] handleSmartPaste called, clientRef:', !!clientRef.current, 'isConnected:', isConnected, 'keyboardCaptureEnabled:', keyboardCaptureEnabled); + + if (!clientRef.current || !isConnected || !keyboardCaptureEnabled) { + console.log('[Clipboard] handleSmartPaste early return'); + return; + } + + try { + // Get clipboard content from host + console.log('[Clipboard] Reading clipboard content...'); + const text = await navigator.clipboard.readText(); + console.log('[Clipboard] Clipboard content:', text); + + if (text) { + // Limit clipboard content length to prevent OOM issues + const maxLength = 10000; // 10KB limit + const truncatedText = text.length > maxLength ? text.substring(0, maxLength) + '...' : text; + + console.log('[Clipboard] Smart paste to device:', truncatedText); + console.log('[Clipboard] Original length:', text.length, 'Truncated length:', truncatedText.length); + + // Send set clipboard command with paste flag + // Format: [Sequence (8 bytes)][Paste flag (1 byte)][Text length (4 bytes)][Text data] + // Note: Type is handled by sendControlMessage type parameter, not in buffer + const textBytes = new TextEncoder().encode(truncatedText); + const textLength = textBytes.length; + const buffer = new Uint8Array(8 + 1 + 4 + textLength); + let offset = 0; + + // Sequence (8 bytes, big endian) - use 0 for now + buffer[offset++] = 0; + buffer[offset++] = 0; + buffer[offset++] = 0; + buffer[offset++] = 0; + buffer[offset++] = 0; + buffer[offset++] = 0; + buffer[offset++] = 0; + buffer[offset++] = 0; + + // Paste flag (1 byte) - 1 for set and paste + buffer[offset++] = 1; + + // Text length (4 bytes, big endian) - use actual text length + buffer[offset++] = (textLength >> 24) & 0xFF; + buffer[offset++] = (textLength >> 16) & 0xFF; + buffer[offset++] = (textLength >> 8) & 0xFF; + buffer[offset++] = textLength & 0xFF; + + // Text data + buffer.set(textBytes, offset); + + // Debug: verify buffer size matches expected size + const expectedSize = 8 + 1 + 4 + textLength; + if (buffer.length !== expectedSize) { + console.error(`ERROR: Buffer size mismatch! Expected: ${expectedSize}, Actual: ${buffer.length}`); + } + + clientRef.current.sendControlMessage({ + type: 9, // TYPE_SET_CLIPBOARD + data: buffer + }); + } + } catch (error) { + console.error('[Clipboard] Failed to read clipboard:', error); + } + }, [isConnected, keyboardCaptureEnabled, clientRef]); + + // Smart clipboard sync - copy from device + const handleSmartCopy = useCallback(() => { + if (!clientRef.current || !isConnected || !keyboardCaptureEnabled) return; + + console.log('[Clipboard] Smart copy from device'); + // Send get clipboard command + clientRef.current.sendControlMessage({ + type: 8, // TYPE_GET_CLIPBOARD + data: new Uint8Array(0) + }); + }, [isConnected, keyboardCaptureEnabled, clientRef]); + + return { + handleSmartPaste, + handleSmartCopy, + }; +}; diff --git a/packages/live-view/src/hooks/useControlHandler.ts b/packages/live-view/src/hooks/useControlHandler.ts new file mode 100644 index 00000000..7665ee6c --- /dev/null +++ b/packages/live-view/src/hooks/useControlHandler.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; +import { WebRTCClient } from '../lib/webrtc-client'; + +interface UseControlHandlerProps { + clientRef: React.RefObject; + isConnected: boolean; +} + +export const useControlHandler = ({ clientRef, isConnected }: UseControlHandlerProps) => { + const handleControlAction = useCallback((action: string) => { + if (!clientRef.current) return; + + const keycodes = WebRTCClient.ANDROID_KEYCODES as any; + const keycode = keycodes[action.toUpperCase()]; + + if (keycode) { + clientRef.current.sendKeyEvent(keycode, 'down'); + setTimeout(() => { + clientRef.current?.sendKeyEvent(keycode, 'up'); + }, 100); + } + }, [clientRef]); + + // Handle IME switch button click + const handleIMESwitch = useCallback(() => { + if (!clientRef.current || !isConnected) return; + + console.log('[IME] Switching input method'); + // Send language switch keycode (204) + clientRef.current.sendKeyEvent(204, 'down'); + setTimeout(() => { + clientRef.current?.sendKeyEvent(204, 'up'); + }, 50); + }, [isConnected, clientRef]); + + return { + handleControlAction, + handleIMESwitch, + }; +}; diff --git a/packages/live-view/src/hooks/useDeviceManager.ts b/packages/live-view/src/hooks/useDeviceManager.ts new file mode 100644 index 00000000..d2355320 --- /dev/null +++ b/packages/live-view/src/hooks/useDeviceManager.ts @@ -0,0 +1,61 @@ +import { useState, useCallback, useEffect } from 'react'; +import { Device } from '../types'; + +interface UseDeviceManagerProps { + apiUrl: string; + showDeviceList: boolean; + autoConnect: boolean; + deviceSerial?: string; + isConnected: boolean; + onError?: (error: Error) => void; +} + +export const useDeviceManager = ({ + apiUrl, + showDeviceList, + autoConnect, + deviceSerial, + isConnected, + onError, +}: UseDeviceManagerProps) => { + const [devices, setDevices] = useState([]); + const [currentDevice, setCurrentDevice] = useState(null); + const [loading, setLoading] = useState(false); + + // Load devices + const loadDevices = useCallback(async () => { + setLoading(true); + try { + const response = await fetch(`${apiUrl}/devices`); + const data = await response.json(); + // Transform device data to match our interface + // The API returns 'id' but we use 'serial' internally + const transformedDevices = (data.devices || []).map((device: any) => ({ + serial: device.id || device.serial || device.udid, + state: device.state, + model: device['ro.product.model'] || device.model || 'Unknown', + connected: device.connected, + })); + setDevices(transformedDevices); + } catch (error) { + console.error('Failed to load devices:', error); + onError?.(error as Error); + } finally { + setLoading(false); + } + }, [apiUrl, onError]); + + useEffect(() => { + if (showDeviceList) { + loadDevices(); + } + }, [showDeviceList, loadDevices]); + + return { + devices, + currentDevice, + loading, + setCurrentDevice, + loadDevices, + }; +}; diff --git a/packages/live-view/src/hooks/useKeyboardHandler.ts b/packages/live-view/src/hooks/useKeyboardHandler.ts new file mode 100644 index 00000000..d22af817 --- /dev/null +++ b/packages/live-view/src/hooks/useKeyboardHandler.ts @@ -0,0 +1,279 @@ +import { useCallback } from 'react'; +import { WebRTCClient } from '../lib/webrtc-client'; + +interface UseKeyboardHandlerProps { + clientRef: React.RefObject; + isConnected: boolean; + keyboardCaptureEnabled: boolean; + onSmartPaste: () => void; + onSmartCopy: () => void; +} + +export const useKeyboardHandler = ({ + clientRef, + isConnected, + keyboardCaptureEnabled, + onSmartPaste, + onSmartCopy, +}: UseKeyboardHandlerProps) => { + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + console.log('[Keyboard] Key down:', e.key, 'code:', e.code, 'keyCode:', e.keyCode, 'isConnected:', isConnected, 'captureEnabled:', keyboardCaptureEnabled); + + // Handle smart clipboard sync (when keyboard capture is enabled) + if (keyboardCaptureEnabled && isConnected) { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + const isCtrlOrCmd = isMac ? e.metaKey : e.ctrlKey; + + // Handle Cmd/Ctrl key combinations + if (isCtrlOrCmd) { + if (e.key.toLowerCase() === 'v') { + // Cmd+V or Ctrl+V - paste to device + console.log('[Clipboard] Smart paste triggered by Cmd+V/Ctrl+V'); + e.preventDefault(); + e.stopPropagation(); + onSmartPaste(); + return; + } + + if (e.key.toLowerCase() === 'c') { + // Cmd+C or Ctrl+C - copy from device + console.log('[Clipboard] Smart copy triggered by Cmd+C/Ctrl+C'); + e.preventDefault(); + e.stopPropagation(); + onSmartCopy(); + return; + } + + if (e.key.toLowerCase() === 'a') { + // Cmd+A or Ctrl+A - select all + console.log('[Keyboard] Select all triggered by Cmd+A/Ctrl+A'); + e.preventDefault(); + e.stopPropagation(); + + // Send Ctrl+A combination to device with proper timing + if (clientRef.current) { + const META_CTRL_ON = 0x1000; // Android meta state for Ctrl key + + // Send Ctrl down first + clientRef.current.sendKeyEvent(113, 'down', META_CTRL_ON); // KEYCODE_CTRL_LEFT + + // Small delay to ensure Ctrl is registered + setTimeout(() => { + if (clientRef.current) { + // Send A down with Ctrl meta state + clientRef.current.sendKeyEvent(29, 'down', META_CTRL_ON); // KEYCODE_A + + // Small delay before releasing A + setTimeout(() => { + if (clientRef.current) { + // Send A up with Ctrl meta state + clientRef.current.sendKeyEvent(29, 'up', META_CTRL_ON); // KEYCODE_A + + // Small delay before releasing Ctrl + setTimeout(() => { + if (clientRef.current) { + // Send Ctrl up + clientRef.current.sendKeyEvent(113, 'up', 0); // KEYCODE_CTRL_LEFT + } + }, 10); + } + }, 10); + } + }, 10); + } + return; + } + + // Prevent Cmd/Ctrl key from being sent to device when used in combinations + if (e.key === 'Meta' || e.key === 'Control') { + console.log('[Keyboard] Preventing Cmd/Ctrl key from being sent to device'); + e.preventDefault(); + e.stopPropagation(); + return; + } + } + } + + if (!clientRef.current || !isConnected || !keyboardCaptureEnabled) return; + + // Map keyboard codes to Android keycodes (using e.code like ws-scrcpy) + const keyMap: { [code: string]: number } = { + // Functional keys + 'Enter': 66, + 'Backspace': 67, + 'Delete': 112, + 'Escape': 111, + 'Tab': 61, + 'Space': 62, + 'CapsLock': 115, + 'ShiftLeft': 59, + 'ShiftRight': 60, + 'ControlLeft': 113, + 'ControlRight': 114, + 'AltLeft': 57, + 'AltRight': 58, + 'MetaLeft': 117, + 'MetaRight': 118, + + // Arrow keys + 'ArrowUp': 19, + 'ArrowDown': 20, + 'ArrowLeft': 21, + 'ArrowRight': 22, + + // Navigation keys + 'Home': 122, + 'End': 123, + 'PageUp': 92, + 'PageDown': 93, + 'Insert': 124, + + // Letters + 'KeyA': 29, 'KeyB': 30, 'KeyC': 31, 'KeyD': 32, 'KeyE': 33, + 'KeyF': 34, 'KeyG': 35, 'KeyH': 36, 'KeyI': 37, 'KeyJ': 38, + 'KeyK': 39, 'KeyL': 40, 'KeyM': 41, 'KeyN': 42, 'KeyO': 43, + 'KeyP': 44, 'KeyQ': 45, 'KeyR': 46, 'KeyS': 47, 'KeyT': 48, + 'KeyU': 49, 'KeyV': 50, 'KeyW': 51, 'KeyX': 52, 'KeyY': 53, + 'KeyZ': 54, + + // Numbers + 'Digit0': 7, 'Digit1': 8, 'Digit2': 9, 'Digit3': 10, 'Digit4': 11, + 'Digit5': 12, 'Digit6': 13, 'Digit7': 14, 'Digit8': 15, 'Digit9': 16, + + // Symbols + 'Period': 56, 'Comma': 55, 'Slash': 76, 'Semicolon': 74, 'Quote': 75, + 'BracketLeft': 71, 'BracketRight': 72, 'Backslash': 73, 'Minus': 69, 'Equal': 70, + 'Backquote': 68, + + // Function keys + 'F1': 131, 'F2': 132, 'F3': 133, 'F4': 134, 'F5': 135, 'F6': 136, + 'F7': 137, 'F8': 138, 'F9': 139, 'F10': 140, 'F11': 141, 'F12': 142, + + // Input method keys (for triggering IME) + 'Lang1': 204, // Language switch (most common for IME) + 'Lang2': 204, // Alternative language switch + 'Convert': 214, // Convert (Japanese IME) + 'NonConvert': 213, // Non-convert (Japanese IME) + 'KanaMode': 218, // Kana mode (Japanese IME) + }; + + const keycode = keyMap[e.code]; + + if (keycode) { + // Calculate meta state for modifier keys + let metaState = 0; + if (e.shiftKey) metaState |= 0x0001; // META_SHIFT_ON + if (e.ctrlKey) metaState |= 0x1000; // META_CTRL_ON + if (e.altKey) metaState |= 0x0002; // META_ALT_ON + if (e.metaKey) metaState |= 0x10000; // META_META_ON + + console.log('[Keyboard] Sending key down:', keycode, 'metaState:', metaState, 'shiftKey:', e.shiftKey); + e.preventDefault(); + e.stopPropagation(); + clientRef.current.sendKeyEvent(keycode, 'down', metaState); + } else { + console.log('[Keyboard] No keycode found for key:', e.key); + } + }, [isConnected, keyboardCaptureEnabled, onSmartPaste, onSmartCopy, clientRef]); + + const handleKeyUp = useCallback((e: React.KeyboardEvent) => { + console.log('[Keyboard] Key up:', e.key, 'code:', e.code, 'isConnected:', isConnected, 'captureEnabled:', keyboardCaptureEnabled); + + // Handle smart clipboard sync (when keyboard capture is enabled) + if (keyboardCaptureEnabled && isConnected) { + // Prevent Cmd/Ctrl key from being sent to device when used in combinations + if (e.key === 'Meta' || e.key === 'Control') { + console.log('[Keyboard] Preventing Cmd/Ctrl keyup from being sent to device'); + e.preventDefault(); + e.stopPropagation(); + return; + } + } + + if (!clientRef.current || !isConnected || !keyboardCaptureEnabled) return; + + // Map keyboard codes to Android keycodes (using e.code like ws-scrcpy) + const keyMap: { [code: string]: number } = { + // Functional keys + 'Enter': 66, + 'Backspace': 67, + 'Delete': 112, + 'Escape': 111, + 'Tab': 61, + 'Space': 62, + 'CapsLock': 115, + 'ShiftLeft': 59, + 'ShiftRight': 60, + 'ControlLeft': 113, + 'ControlRight': 114, + 'AltLeft': 57, + 'AltRight': 58, + 'MetaLeft': 117, + 'MetaRight': 118, + + // Arrow keys + 'ArrowUp': 19, + 'ArrowDown': 20, + 'ArrowLeft': 21, + 'ArrowRight': 22, + + // Navigation keys + 'Home': 122, + 'End': 123, + 'PageUp': 92, + 'PageDown': 93, + 'Insert': 124, + + // Letters + 'KeyA': 29, 'KeyB': 30, 'KeyC': 31, 'KeyD': 32, 'KeyE': 33, + 'KeyF': 34, 'KeyG': 35, 'KeyH': 36, 'KeyI': 37, 'KeyJ': 38, + 'KeyK': 39, 'KeyL': 40, 'KeyM': 41, 'KeyN': 42, 'KeyO': 43, + 'KeyP': 44, 'KeyQ': 45, 'KeyR': 46, 'KeyS': 47, 'KeyT': 48, + 'KeyU': 49, 'KeyV': 50, 'KeyW': 51, 'KeyX': 52, 'KeyY': 53, + 'KeyZ': 54, + + // Numbers + 'Digit0': 7, 'Digit1': 8, 'Digit2': 9, 'Digit3': 10, 'Digit4': 11, + 'Digit5': 12, 'Digit6': 13, 'Digit7': 14, 'Digit8': 15, 'Digit9': 16, + + // Symbols + 'Period': 56, 'Comma': 55, 'Slash': 76, 'Semicolon': 74, 'Quote': 75, + 'BracketLeft': 71, 'BracketRight': 72, 'Backslash': 73, 'Minus': 69, 'Equal': 70, + 'Backquote': 68, + + // Function keys + 'F1': 131, 'F2': 132, 'F3': 133, 'F4': 134, 'F5': 135, 'F6': 136, + 'F7': 137, 'F8': 138, 'F9': 139, 'F10': 140, 'F11': 141, 'F12': 142, + + // Input method keys (for triggering IME) + 'Lang1': 204, // Language switch (most common for IME) + 'Lang2': 204, // Alternative language switch + 'Convert': 214, // Convert (Japanese IME) + 'NonConvert': 213, // Non-convert (Japanese IME) + 'KanaMode': 218, // Kana mode (Japanese IME) + }; + + const keycode = keyMap[e.code]; + + if (keycode) { + // Calculate meta state for modifier keys + let metaState = 0; + if (e.shiftKey) metaState |= 0x0001; // META_SHIFT_ON + if (e.ctrlKey) metaState |= 0x1000; // META_CTRL_ON + if (e.altKey) metaState |= 0x0002; // META_ALT_ON + if (e.metaKey) metaState |= 0x10000; // META_META_ON + + console.log('[Keyboard] Sending key up:', keycode, 'metaState:', metaState, 'shiftKey:', e.shiftKey); + e.preventDefault(); + e.stopPropagation(); + clientRef.current.sendKeyEvent(keycode, 'up', metaState); + } else { + console.log('[Keyboard] No keycode found for key:', e.key); + } + }, [isConnected, keyboardCaptureEnabled, clientRef]); + + return { + handleKeyDown, + handleKeyUp, + }; +}; diff --git a/packages/live-view/src/hooks/useMouseHandler.ts b/packages/live-view/src/hooks/useMouseHandler.ts new file mode 100644 index 00000000..b4423be2 --- /dev/null +++ b/packages/live-view/src/hooks/useMouseHandler.ts @@ -0,0 +1,60 @@ +import { useCallback, useState } from 'react'; +import { WebRTCClient } from '../lib/webrtc-client'; + +interface UseMouseHandlerProps { + clientRef: React.RefObject; +} + +export const useMouseHandler = ({ clientRef }: UseMouseHandlerProps) => { + const [isDragging, setIsDragging] = useState(false); + const [touchPosition, setTouchPosition] = useState({ x: 0, y: 0 }); + + const handleMouseInteraction = useCallback((e: React.MouseEvent) => { + if (!clientRef.current) return; + + const action = e.type === 'mousedown' ? 'down' : + e.type === 'mouseup' ? 'up' : 'move'; + + // Handle mouse event in WebRTC client + clientRef.current.handleMouseEvent(e.nativeEvent, action); + + // Update dragging state and touch indicator position + if (action === 'down') { + setIsDragging(true); + setTouchPosition({ x: e.clientX, y: e.clientY }); + } else if (action === 'up') { + setIsDragging(false); + // Hide indicator immediately + setTouchPosition({ x: -100, y: -100 }); + } else if (action === 'move' && clientRef.current.isMouseDragging) { + // Update position immediately during drag + setTouchPosition({ x: e.clientX, y: e.clientY }); + } + }, [clientRef]); + + const handleTouchInteraction = useCallback((e: React.TouchEvent) => { + if (!clientRef.current) return; + + const action = e.type === 'touchstart' ? 'down' : + e.type === 'touchend' ? 'up' : 'move'; + + clientRef.current.handleTouchEvent(e.nativeEvent, action); + }, [clientRef]); + + const handleMouseLeave = useCallback((e: React.MouseEvent) => { + // Release drag if mouse leaves the video element + if (clientRef.current && clientRef.current.isMouseDragging) { + clientRef.current.handleMouseEvent(e.nativeEvent, 'up'); + setIsDragging(false); + setTouchPosition({ x: -100, y: -100 }); + } + }, [clientRef]); + + return { + isDragging, + touchPosition, + handleMouseInteraction, + handleTouchInteraction, + handleMouseLeave, + }; +}; diff --git a/packages/live-view/src/hooks/useWheelHandler.ts b/packages/live-view/src/hooks/useWheelHandler.ts new file mode 100644 index 00000000..15fe096e --- /dev/null +++ b/packages/live-view/src/hooks/useWheelHandler.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef } from 'react'; +import { WebRTCClient } from '../lib/webrtc-client'; + +interface UseWheelHandlerProps { + videoRef: React.RefObject; + clientRef: React.RefObject; + isConnected: boolean; +} + +export const useWheelHandler = ({ videoRef, clientRef, isConnected }: UseWheelHandlerProps) => { + // Handle wheel events with non-passive listener to allow preventDefault + useEffect(() => { + const videoElement = videoRef.current; + if (!videoElement) return; + + const handleWheel = (e: WheelEvent) => { + if (!clientRef.current || !isConnected) return; + + e.preventDefault(); + e.stopPropagation(); + + // Send scroll event (exactly like scrcpy does) + // scrcpy uses event->preciseX/preciseY directly without throttling + const rect = videoElement.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + + // Use precise values like scrcpy (event->preciseX/preciseY) + // Invert for Android (negative values scroll up) + // scrcpy accepts values in range [-16, 16] + let hScroll = -e.deltaX; + let vScroll = -e.deltaY; + + // Apply scrcpy-like scaling for better responsiveness + // scrcpy uses raw values but we need to scale for web compatibility + const scaleFactor = 0.5; // Scale down for web compatibility + hScroll *= scaleFactor; + vScroll *= scaleFactor; + + // Clamp to scrcpy's acceptable range (like scrcpy does) + hScroll = Math.max(-16, Math.min(16, hScroll)); + vScroll = Math.max(-16, Math.min(16, vScroll)); + + console.log('[Wheel] Raw scroll event:', { + deltaX: e.deltaX, + deltaY: e.deltaY, + hScroll, + vScroll, + x, + y, + willSend: (hScroll !== 0 || vScroll !== 0) && (x >= 0 && x <= 1 && y >= 0 && y <= 1) + }); + + // Only send if there's actual scroll movement + if (hScroll !== 0 || vScroll !== 0) { + // Ensure coordinates are valid + if (x >= 0 && x <= 1 && y >= 0 && y <= 1) { + console.log('[Wheel] Sending scroll event:', { x, y, hScroll, vScroll }); + + clientRef.current.sendControlMessage({ + type: "scroll", + x, + y, + hScroll, + vScroll, + timestamp: Date.now(), + }); + } else { + console.warn('[Wheel] Invalid coordinates:', { x, y }); + } + } + }; + + // Add non-passive wheel event listener + videoElement.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + videoElement.removeEventListener('wheel', handleWheel); + }; + }, [isConnected, videoRef, clientRef]); +}; diff --git a/packages/live-view/src/index.ts b/packages/live-view/src/index.ts new file mode 100644 index 00000000..5d971d63 --- /dev/null +++ b/packages/live-view/src/index.ts @@ -0,0 +1,4 @@ +export { LiveView } from './components/LiveView'; +export { AndroidLiveView } from './components/AndroidLiveView'; +export type { LiveViewProps, AndroidLiveViewProps } from './types'; +export { WebRTCClient } from './lib/webrtc-client'; \ No newline at end of file diff --git a/packages/live-view/src/lib/webrtc-client.ts b/packages/live-view/src/lib/webrtc-client.ts new file mode 100644 index 00000000..64f22742 --- /dev/null +++ b/packages/live-view/src/lib/webrtc-client.ts @@ -0,0 +1,1070 @@ +import { ControlMessage, SignalingMessage } from "../types"; + +export class WebRTCClient { + private ws: WebSocket | null = null; + private pc: RTCPeerConnection | null = null; + private dataChannel: RTCDataChannel | null = null; + private currentDevice: string | null = null; + private isConnected: boolean = false; + private statsInterval: number | null = null; + public isMouseDragging: boolean = false; + private lastMouseTime: number = 0; + private videoElement: HTMLVideoElement | null = null; + private audioElement: HTMLAudioElement | null = null; + + // Reconnection state + private isReconnecting: boolean = false; + private reconnectAttempts: number = 0; + private readonly maxReconnectAttempts: number = 30; // Increase for backend restarts + private reconnectTimer: number | null = null; + private lastConnectedDevice: string | null = null; + + // Callbacks + private onConnectionStateChange?: ( + state: "connecting" | "connected" | "disconnected" | "error", + message?: string + ) => void; + private onError?: (error: Error) => void; + private onStatsUpdate?: (stats: any) => void; + + // Android key codes + static readonly ANDROID_KEYCODES = { + POWER: 26, + VOLUME_UP: 24, + VOLUME_DOWN: 25, + BACK: 4, + HOME: 3, + APP_SWITCH: 187, + MENU: 82, + }; + + constructor( + videoElement: HTMLVideoElement, + options: { + onConnectionStateChange?: ( + state: "connecting" | "connected" | "disconnected" | "error", + message?: string + ) => void; + onError?: (error: Error) => void; + onStatsUpdate?: (stats: any) => void; + } = {} + ) { + this.videoElement = videoElement; + this.onConnectionStateChange = options.onConnectionStateChange; + this.onError = options.onError; + this.onStatsUpdate = options.onStatsUpdate; + } + + async connect(deviceSerial: string, wsUrl: string): Promise { + console.log(`[WebRTC] Connecting to device: ${deviceSerial}`); + console.log(`[WebRTC] WebSocket URL: ${wsUrl}`); + + // Always disconnect first to ensure clean state + if (this.isConnected || this.pc || this.ws) { + console.log("[WebRTC] Cleaning up existing connection"); + await this.disconnect(); + // Wait for cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + this.currentDevice = deviceSerial; + this.lastConnectedDevice = deviceSerial; + this.isReconnecting = false; + this.reconnectAttempts = 0; + this.onConnectionStateChange?.("connecting", "正在连接设备..."); + + try { + console.log("[WebRTC] Starting WebRTC connection establishment"); + await this.establishWebRTCConnection(deviceSerial, wsUrl); + } catch (error) { + console.error("[WebRTC] Connection failed:", error); + + // Check if it's a connection closed error that we can retry + const errorMsg = (error as Error).message; + if ( + errorMsg.includes("connection closed") || + errorMsg.includes("InvalidStateError") + ) { + console.log( + "[WebRTC] Connection closed error, will retry automatically" + ); + this.onConnectionStateChange?.( + "disconnected", + "连接已关闭,正在重连..." + ); + return; // Don't throw error, let automatic reconnection handle it + } + + this.onError?.(error as Error); + this.onConnectionStateChange?.("error", "连接失败"); + throw error; + } + } + + private async establishWebRTCConnection( + deviceSerial: string, + wsUrl: string + ): Promise { + const fullWsUrl = `${wsUrl}?device=${deviceSerial}`; + console.log(`[WebRTC] Creating WebSocket connection to: ${fullWsUrl}`); + this.ws = new WebSocket(fullWsUrl); + + // Create WebRTC peer connection with ultra-low-latency optimizations + this.pc = new RTCPeerConnection({ + iceServers: [], + bundlePolicy: "max-bundle", + rtcpMuxPolicy: "require", + iceCandidatePoolSize: 0, // Disable candidate pool for faster connection + // Ultra-low latency optimizations + iceTransportPolicy: "all", + }); + + // Create data channel for control messages with ultra-low latency settings + this.dataChannel = this.pc.createDataChannel("control", { + ordered: false, // Allow out-of-order delivery for lower latency + maxRetransmits: 0, // No retransmissions for lower latency + // Note: Cannot use both maxRetransmits and maxPacketLifeTime together + }); + this.setupDataChannel(); + console.log("[WebRTC] Created data channel: control"); + + // Add transceivers + const videoTransceiver = this.pc.addTransceiver("video", { + direction: "recvonly", + }); + const audioTransceiver = this.pc.addTransceiver("audio", { + direction: "recvonly", + }); + + // Set ultra-low latency hints and optimizations + if ("playoutDelayHint" in videoTransceiver.receiver) { + (videoTransceiver.receiver as any).playoutDelayHint = 0; + } + if ("playoutDelayHint" in audioTransceiver.receiver) { + (audioTransceiver.receiver as any).playoutDelayHint = 0; + } + + // Set jitter buffer settings for lower latency + if ("jitterBufferTarget" in videoTransceiver.receiver) { + (videoTransceiver.receiver as any).jitterBufferTarget = 0; + } + if ("jitterBufferTarget" in audioTransceiver.receiver) { + (audioTransceiver.receiver as any).jitterBufferTarget = 0; + } + + // Configure transceivers for low latency + if (videoTransceiver.sender && "setParameters" in videoTransceiver.sender) { + try { + const params = videoTransceiver.sender.getParameters(); + if (params.encodings && params.encodings.length > 0) { + // Optimize for ultra-low latency + params.encodings[0].maxBitrate = 12000000; // 12 Mbps max for better quality + params.encodings[0].maxFramerate = 60; + params.encodings[0].scaleResolutionDownBy = 1; // No downscaling + videoTransceiver.sender.setParameters(params); + } + } catch (e) { + console.warn("Failed to set video sender parameters:", e); + } + } + + this.setupWebRTCHandlers(); + this.setupWebSocketHandlers(); + + // Wait for WebSocket to be open, then create offer + await new Promise((resolve, reject) => { + if (!this.ws) { + reject(new Error("WebSocket not initialized")); + return; + } + + const timeout = setTimeout(() => { + reject(new Error("WebSocket connection timeout")); + }, 5000); + + this.ws.onopen = async () => { + clearTimeout(timeout); + console.log("[WebRTC] WebSocket connected, creating offer"); + + try { + // Create and send offer + const offer = await this.pc!.createOffer(); + await this.pc!.setLocalDescription(offer); + + // Send offer with deviceSerial and proper structure + this.ws!.send( + JSON.stringify({ + type: "offer", + deviceSerial: deviceSerial, + offer: { + sdp: offer.sdp, + }, + }) + ); + + console.log("[WebRTC] Offer sent to server"); + resolve(); + } catch (error) { + reject(error); + } + }; + + this.ws.onerror = (error) => { + clearTimeout(timeout); + console.error("[WebRTC] WebSocket connection error:", error); + reject(new Error("WebSocket connection error")); + }; + }); + } + + private setupWebRTCHandlers(): void { + if (!this.pc) return; + + this.pc.ontrack = (event) => { + console.log( + "[WebRTC] Track received:", + event.track.kind, + "Track ID:", + event.track.id + ); + if (event.track.kind === "video" && this.videoElement) { + console.log("[WebRTC] Video track received, setting up playback"); + event.track.enabled = true; + this.videoElement.autoplay = true; + this.videoElement.muted = false; + this.videoElement.playsInline = true; + this.videoElement.controls = false; + this.videoElement.srcObject = event.streams[0]; + + // Optimize video element for ultra-low latency + this.videoElement.preload = "none"; + this.videoElement.defaultMuted = false; + // Additional low-latency optimizations + this.videoElement.style.objectFit = "contain"; + this.videoElement.style.background = "black"; + // Disable buffering optimizations that add latency + if ("webkitPreservesPitch" in this.videoElement) { + (this.videoElement as any).webkitPreservesPitch = false; + } + + // Set low latency playback hints if available + if ("requestVideoFrameCallback" in this.videoElement) { + // Use modern frame callback for better timing + (this.videoElement as any).requestVideoFrameCallback(() => { + // Frame rendered callback for timing analysis + }); + } + console.log("[WebRTC] Video srcObject set"); + + this.videoElement.onloadedmetadata = () => { + if (!this.videoElement) return; + const width = this.videoElement.videoWidth; + const height = this.videoElement.videoHeight; + console.log("[WebRTC] Video metadata loaded:", `${width}x${height}`); + if (width && height) { + this.onStatsUpdate?.({ resolution: `${width}x${height}` }); + } + + // Optimize buffering for low latency + this.optimizeVideoBuffering(); + }; + + this.onConnectionStateChange?.("connected", undefined); + this.isConnected = true; + this.startStats(); + } else if (event.track.kind === "audio") { + console.log("[WebRTC] Audio track received"); + this.setupAudioPlayback(event.track, event.streams[0]); + } + }; + + this.pc.onicecandidate = (event) => { + if (event.candidate && this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send( + JSON.stringify({ + type: "ice-candidate", + deviceSerial: this.currentDevice, + candidate: event.candidate, + }) + ); + } + }; + + this.pc.ondatachannel = (event) => { + console.log( + "[WebRTC] Data channel received from server:", + event.channel.label + ); + // Only use server's data channel if we don't have one + if (!this.dataChannel) { + this.dataChannel = event.channel; + this.setupDataChannel(); + } + }; + + this.pc.onconnectionstatechange = () => { + if (!this.pc) return; + console.log("[WebRTC] Connection state:", this.pc.connectionState); + if ( + this.pc.connectionState === "failed" || + this.pc.connectionState === "disconnected" + ) { + this.isConnected = false; + // Don't show error immediately, try to reconnect first + if (this.lastConnectedDevice && !this.isReconnecting) { + console.log("[WebRTC] Connection lost, starting reconnection..."); + this.onConnectionStateChange?.( + "connecting", + "Connection lost, reconnecting..." + ); + this.startReconnection(); + } else if (!this.lastConnectedDevice) { + this.onConnectionStateChange?.("error", "Connection lost"); + } + } else if (this.pc.connectionState === "connected") { + console.log("[WebRTC] Peer connection established successfully"); + } + }; + } + + private setupAudioPlayback( + track: MediaStreamTrack, + stream: MediaStream + ): void { + if (this.audioElement) { + this.audioElement.pause(); + this.audioElement.srcObject = null; + this.audioElement.remove(); + this.audioElement = null; + } + + this.audioElement = document.createElement("audio"); + this.audioElement.autoplay = true; + (this.audioElement as any).playsInline = true; + this.audioElement.controls = false; + this.audioElement.preload = "none"; + this.audioElement.srcObject = stream || new MediaStream([track]); + track.enabled = true; + document.body.appendChild(this.audioElement); + + // Optimize audio for low latency + if ("setSinkId" in this.audioElement) { + // Use default audio device for lowest latency + (this.audioElement as any).setSinkId("default").catch(() => { + // Ignore if setSinkId fails + }); + } + + this.audioElement.play().catch((e) => { + console.error("Audio playback failed:", e); + this.onError?.(new Error("音频播放失败,点击页面启用音频")); + }); + } + + private setupWebSocketHandlers(): void { + if (!this.ws) return; + + this.ws.onmessage = async (event) => { + const message: SignalingMessage = JSON.parse(event.data); + await this.handleSignalingMessage(message); + }; + + this.ws.onclose = () => { + // WebSocket closed - expected after WebRTC connection established + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + this.onError?.(new Error("WebSocket connection error")); + }; + } + + private async handleSignalingMessage( + message: SignalingMessage + ): Promise { + if (!this.pc) return; + + console.log("[WebRTC] Received signaling message:", message.type); + + switch (message.type) { + case "offer": + console.log("[WebRTC] Setting remote offer"); + await this.pc.setRemoteDescription( + new RTCSessionDescription({ + type: "offer", + sdp: message.sdp!, + }) + ); + const answer = await this.pc.createAnswer(); + await this.pc.setLocalDescription(answer); + console.log("[WebRTC] Sending answer"); + this.sendSignalingMessage({ + type: "answer", + sdp: answer.sdp, + }); + break; + + case "answer": + console.log("[WebRTC] Setting remote answer"); + console.log("[WebRTC] Message keys:", Object.keys(message)); + console.log("[WebRTC] Message.sdp exists:", !!message.sdp); + console.log( + "[WebRTC] Message.answer exists:", + !!(message as any).answer + ); + + // Handle both formats: direct sdp or nested in answer object + const sdp = message.sdp || (message as any).answer?.sdp; + if (!sdp) { + console.error( + "[WebRTC] Answer missing SDP field, full message:", + JSON.stringify(message) + ); + break; + } + await this.pc.setRemoteDescription( + new RTCSessionDescription({ + type: "answer", + sdp: sdp, + }) + ); + console.log("[WebRTC] Remote answer set successfully"); + break; + + case "ice-candidate": + if (message.candidate) { + console.log("[WebRTC] Adding ICE candidate"); + await this.pc.addIceCandidate(new RTCIceCandidate(message.candidate)); + } + break; + + case "error": + console.error("[WebRTC] Server error:", message.error); + const errorMsg = message.error || "Unknown server error"; + + // Handle specific connection errors that need reconnection + if ( + errorMsg.includes("connection closed") || + errorMsg.includes("InvalidStateError") || + errorMsg.includes("InvalidModificationError") || + errorMsg.includes("invalid proposed signaling state") + ) { + console.log( + "[WebRTC] Connection error detected, will attempt reconnection:", + errorMsg + ); + this.isConnected = false; + + // Clean up current connection before reconnecting + if (this.pc) { + try { + this.pc.close(); + } catch (e) { + console.warn( + "[WebRTC] Error closing peer connection during error recovery:", + e + ); + } + this.pc = null; + } + + // Use the standard reconnection mechanism + if ( + this.currentDevice && + this.lastConnectedDevice && + !this.isReconnecting + ) { + this.onConnectionStateChange?.( + "connecting", + "Connection error, reconnecting..." + ); + + // Wait a bit before reconnecting to allow server to clean up + setTimeout(() => { + this.startReconnection(); + }, 500); + } + + // Don't trigger error callback for recoverable errors + return; + } + + this.onError?.(new Error(errorMsg)); + this.onConnectionStateChange?.("error", errorMsg); + break; + + default: + console.log("[WebRTC] Unknown message type:", message.type); + } + } + + private sendSignalingMessage(message: SignalingMessage): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + private setupDataChannel(): void { + if (!this.dataChannel) return; + + this.dataChannel.onopen = () => { + console.log("[WebRTC] Data channel opened"); + setTimeout(() => this.requestKeyframe(), 500); + }; + + this.dataChannel.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + if (message.type === "pong" && message.id) { + // Handle ping response + } + } catch (e) { + // Not JSON + } + }; + } + + sendControlMessage(message: ControlMessage): void { + if (!this.dataChannel) { + console.warn("[WebRTC] Data channel not available"); + return; + } + + if (this.dataChannel.readyState !== "open") { + console.warn( + "[WebRTC] Data channel not open, state:", + this.dataChannel.readyState + ); + return; + } + + // Check if peer connection is still valid + if ( + !this.pc || + this.pc.connectionState === "closed" || + this.pc.connectionState === "failed" + ) { + console.warn("[WebRTC] Peer connection not ready for control message", { + connectionState: this.pc?.connectionState, + }); + return; + } + + const msgWithTimestamp = { + ...message, + timestamp: Date.now(), + }; + + // Only log non-movement control messages + if (message.type !== "touch" || message.action !== "move") { + console.log("[WebRTC] Sending control message:", msgWithTimestamp); + } + + // Handle clipboard messages with binary data specially + if (typeof message.type === "number" && message.data) { + // For clipboard messages, send as binary data + const binaryMessage = { + type: message.type, + data: Array.from(message.data), // Convert Uint8Array to regular array for JSON + timestamp: Date.now(), + }; + this.dataChannel.send(JSON.stringify(binaryMessage)); + } else { + // For regular messages, send as JSON + this.dataChannel.send(JSON.stringify(msgWithTimestamp)); + } + } + + sendKeyEvent( + keycode: number, + action: "down" | "up", + metaState: number = 0 + ): void { + console.log("[WebRTC] Sending key event:", { keycode, action, metaState }); + this.sendControlMessage({ + type: "key", + action, + keycode, + metaState, + }); + } + + sendClipboardSet(text: string, paste: boolean = false): void { + console.log("[WebRTC] Sending clipboard set:", { text, paste }); + this.sendControlMessage({ + type: "clipboard_set", + text, + paste, + }); + } + + sendTouchEvent( + x: number, + y: number, + action: "down" | "up" | "move", + pressure: number = 1.0 + ): void { + this.sendControlMessage({ + type: "touch", + action, + x, + y, + pressure: action === "down" || action === "move" ? pressure : 0, + pointerId: 0, + }); + } + + handleMouseEvent(event: MouseEvent, action: "down" | "up" | "move"): void { + if ( + !this.isConnected || + !this.dataChannel || + !this.videoElement || + !this.pc + ) { + // Silently return - connection not ready + return; + } + + // Check if peer connection is in a valid state + if ( + this.pc.connectionState === "closed" || + this.pc.connectionState === "failed" + ) { + // Silently return - peer connection not ready + return; + } + + // Handle drag state + if (action === "down") { + this.isMouseDragging = true; + this.lastMouseTime = 0; // Reset throttle + event.preventDefault(); // Prevent text selection during drag + } else if (action === "up") { + this.isMouseDragging = false; + } else if (action === "move" && !this.isMouseDragging) { + // Only send move events when dragging (simulating touch drag) + return; + } + + // Throttle move events to reduce latency (max 120 events per second for better responsiveness) + if (action === "move") { + const now = Date.now(); + if (this.lastMouseTime && now - this.lastMouseTime < 8) { + // 8ms = ~120fps for smoother interaction + return; + } + this.lastMouseTime = now; + } + + // Calculate the actual video display area within the video element + // This is needed because object-fit: contain may add letterboxing/pillarboxing + const rect = this.videoElement.getBoundingClientRect(); + const videoWidth = this.videoElement.videoWidth; + const videoHeight = this.videoElement.videoHeight; + + if (!videoWidth || !videoHeight) { + // Video not loaded yet, use simple calculation + const x = (event.clientX - rect.left) / rect.width; + const y = (event.clientY - rect.top) / rect.height; + + const clampedX = Math.max(0, Math.min(1, x)); + const clampedY = Math.max(0, Math.min(1, y)); + + this.sendControlMessage({ + type: "touch", + action, + x: clampedX, + y: clampedY, + pressure: + action === "down" || (action === "move" && this.isMouseDragging) + ? 1.0 + : 0.0, + pointerId: 0, + }); + return; + } + + // Calculate the actual display dimensions considering aspect ratio + const containerAspect = rect.width / rect.height; + const videoAspect = videoWidth / videoHeight; + + let displayWidth: number; + let displayHeight: number; + let offsetX: number; + let offsetY: number; + + if (containerAspect > videoAspect) { + // Container is wider than video - black bars on left/right (pillarboxing) + displayHeight = rect.height; + displayWidth = displayHeight * videoAspect; + offsetX = (rect.width - displayWidth) / 2; + offsetY = 0; + } else { + // Container is taller than video - black bars on top/bottom (letterboxing) + displayWidth = rect.width; + displayHeight = displayWidth / videoAspect; + offsetX = 0; + offsetY = (rect.height - displayHeight) / 2; + } + + // Calculate relative position within the actual video display area + const relativeX = event.clientX - rect.left - offsetX; + const relativeY = event.clientY - rect.top - offsetY; + + // Convert to normalized coordinates (0-1) + const x = relativeX / displayWidth; + const y = relativeY / displayHeight; + + // Ensure coordinates are within bounds + const clampedX = Math.max(0, Math.min(1, x)); + const clampedY = Math.max(0, Math.min(1, y)); + + this.sendControlMessage({ + type: "touch", + action, + x: clampedX, + y: clampedY, + pressure: + action === "down" || (action === "move" && this.isMouseDragging) + ? 1.0 + : 0.0, + pointerId: 0, // Use 0 for mouse to simulate touch + }); + } + + handleTouchEvent(event: TouchEvent, action: "down" | "up" | "move"): void { + if (!this.isConnected || !this.dataChannel || !this.videoElement) return; + + event.preventDefault(); + + const rect = this.videoElement.getBoundingClientRect(); + const touch = event.touches[0] || event.changedTouches[0]; + const videoWidth = this.videoElement.videoWidth; + const videoHeight = this.videoElement.videoHeight; + + if (!videoWidth || !videoHeight) { + // Video not loaded yet, use simple calculation + const x = (touch.clientX - rect.left) / rect.width; + const y = (touch.clientY - rect.top) / rect.height; + + this.sendControlMessage({ + type: "touch", + action, + x: Math.max(0, Math.min(1, x)), + y: Math.max(0, Math.min(1, y)), + pressure: action === "down" || action === "move" ? 1.0 : 0.0, + pointerId: 0, + }); + return; + } + + // Calculate the actual display dimensions considering aspect ratio + const containerAspect = rect.width / rect.height; + const videoAspect = videoWidth / videoHeight; + + let displayWidth: number; + let displayHeight: number; + let offsetX: number; + let offsetY: number; + + if (containerAspect > videoAspect) { + // Container is wider than video - black bars on left/right (pillarboxing) + displayHeight = rect.height; + displayWidth = displayHeight * videoAspect; + offsetX = (rect.width - displayWidth) / 2; + offsetY = 0; + } else { + // Container is taller than video - black bars on top/bottom (letterboxing) + displayWidth = rect.width; + displayHeight = displayWidth / videoAspect; + offsetX = 0; + offsetY = (rect.height - displayHeight) / 2; + } + + // Calculate relative position within the actual video display area + const relativeX = touch.clientX - rect.left - offsetX; + const relativeY = touch.clientY - rect.top - offsetY; + + // Convert to normalized coordinates (0-1) + const x = relativeX / displayWidth; + const y = relativeY / displayHeight; + + this.sendControlMessage({ + type: "touch", + action, + x: Math.max(0, Math.min(1, x)), + y: Math.max(0, Math.min(1, y)), + pressure: action === "down" || action === "move" ? 1.0 : 0.0, + pointerId: 0, + }); + } + + // Wheel event handling is now done in the React component with accumulation + // This method is kept for compatibility but should not be called directly + handleWheelEvent(_event: WheelEvent): void { + console.warn( + "[Wheel] handleWheelEvent called directly - this should be handled by React component" + ); + } + + requestKeyframe(): void { + this.sendControlMessage({ type: "reset_video" }); + } + + // Optimize video buffering for low latency + private optimizeVideoBuffering(): void { + if (!this.videoElement) return; + + // Try to minimize buffering + try { + // Set current time to reduce buffer + if (this.videoElement.buffered.length > 0) { + const bufferedEnd = this.videoElement.buffered.end(0); + const currentTime = this.videoElement.currentTime; + + // If we have too much buffered content, seek to reduce it + if (bufferedEnd - currentTime > 0.5) { + // More than 500ms buffered + console.log("[WebRTC] Reducing video buffer for lower latency"); + this.videoElement.currentTime = bufferedEnd - 0.1; // Keep only 100ms buffer + } + } + } catch (e) { + console.warn("[WebRTC] Failed to optimize video buffering:", e); + } + } + + private startStats(): void { + if (this.statsInterval) { + clearInterval(this.statsInterval); + } + + // Update stats more frequently for better responsiveness + this.statsInterval = window.setInterval(() => { + this.updateStats(); + // Also optimize buffering periodically + this.optimizeVideoBuffering(); + }, 500); // Update every 500ms instead of 1000ms + } + + private lastFramesDecoded = 0; + private lastFramesReceived = 0; + private lastStatsTime = 0; + + private async updateStats(): Promise { + if (!this.pc) return; + + try { + const stats = await this.pc.getStats(); + let fps = 0; + let resolution = ""; + let latency = 0; + + stats.forEach((report: any) => { + if ( + report.type === "inbound-rtp" && + (report.mediaType === "video" || report.kind === "video") + ) { + const width = report.frameWidth || 0; + const height = report.frameHeight || 0; + + // Calculate FPS from frames decoded difference + const currentTime = Date.now(); + const currentFramesDecoded = report.framesDecoded || 0; + + if (this.lastFramesDecoded > 0 && this.lastStatsTime > 0) { + const timeDiff = (currentTime - this.lastStatsTime) / 1000; // in seconds + const framesDiff = currentFramesDecoded - this.lastFramesDecoded; + if (timeDiff > 0 && framesDiff >= 0) { + fps = Math.round(framesDiff / timeDiff); + } + } + + this.lastFramesDecoded = currentFramesDecoded; + this.lastStatsTime = currentTime; + + // Use framesPerSecond if available as fallback + if (fps === 0 && report.framesPerSecond) { + fps = Math.round(report.framesPerSecond); + } + + // Additional fallback: use framesReceived if framesDecoded is not available + if (fps === 0 && report.framesReceived) { + const currentFramesReceived = report.framesReceived || 0; + if ( + this.lastFramesReceived !== undefined && + this.lastStatsTime > 0 + ) { + const timeDiff = (currentTime - this.lastStatsTime) / 1000; + const framesDiff = + currentFramesReceived - this.lastFramesReceived; + if (timeDiff > 0 && framesDiff >= 0) { + fps = Math.round(framesDiff / timeDiff); + } + } + this.lastFramesReceived = currentFramesReceived; + } + + if (width && height) { + resolution = `${width}x${height}`; + } + } + + // Get latency from candidate-pair stats + if (report.type === "candidate-pair" && report.state === "succeeded") { + latency = Math.round(report.currentRoundTripTime * 1000) || 0; // Convert to ms + } + }); + + this.onStatsUpdate?.({ fps, resolution, latency }); + } catch (err) { + console.warn("Failed to get WebRTC stats:", err); + } + } + + private startReconnection(): void { + if (this.isReconnecting || !this.lastConnectedDevice) return; + this.isReconnecting = true; + this.reconnectAttempts = 0; + this.attemptReconnection(); + } + + private async attemptReconnection(): Promise { + if (!this.isReconnecting || !this.lastConnectedDevice) return; + + this.reconnectAttempts++; + // Use shorter delays for faster reconnection: 1s, 2s, 3s, 5s, then 5s repeatedly + const delays = [1000, 2000, 3000, 5000]; + const delay = + delays[Math.min(this.reconnectAttempts - 1, delays.length - 1)]; + + this.onConnectionStateChange?.( + "connecting", + `Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})` + ); + + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.isReconnecting = false; + this.reconnectAttempts = 0; + this.onConnectionStateChange?.( + "error", + "Reconnection failed after maximum attempts" + ); + return; + } + + // Actually attempt to reconnect + try { + // Extract base WebSocket URL + const baseUrl = this.ws?.url?.split("?")[0] || "ws://localhost:29888/ws"; + + console.log( + `[WebRTC] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}` + ); + + // Try to reconnect + await this.connect(this.lastConnectedDevice, baseUrl); + + // If successful, reset counters + this.isReconnecting = false; + this.reconnectAttempts = 0; + console.log("[WebRTC] Reconnection successful"); + } catch (error) { + console.log( + `[WebRTC] Reconnection attempt ${this.reconnectAttempts} failed:`, + error + ); + + // Schedule next attempt + this.reconnectTimer = window.setTimeout(() => { + this.attemptReconnection(); + }, delay); + } + } + + async disconnect(isManual: boolean = true): Promise { + console.log("[WebRTC] Disconnecting..."); + + if (isManual) { + this.lastConnectedDevice = null; + this.stopReconnection(); + } + + this.isConnected = false; + this.onConnectionStateChange?.("disconnected", undefined); + + // Stop stats collection + if (this.statsInterval) { + clearInterval(this.statsInterval); + this.statsInterval = null; + } + + // Close data channel + if (this.dataChannel) { + try { + this.dataChannel.close(); + } catch (e) { + console.warn("[WebRTC] Error closing data channel:", e); + } + this.dataChannel = null; + } + + // Close peer connection + if (this.pc) { + try { + this.pc.close(); + } catch (e) { + console.warn("[WebRTC] Error closing peer connection:", e); + } + this.pc = null; + } + + // Close WebSocket + if (this.ws) { + try { + this.ws.close(); + } catch (e) { + console.warn("[WebRTC] Error closing WebSocket:", e); + } + this.ws = null; + } + + // Clear video element + if (this.videoElement) { + this.videoElement.srcObject = null; + } + + // Clear audio element + if (this.audioElement) { + this.audioElement.pause(); + this.audioElement.srcObject = null; + this.audioElement.remove(); + this.audioElement = null; + } + + // Reset state + this.currentDevice = null; + this.isMouseDragging = false; + this.lastFramesDecoded = 0; + this.lastFramesReceived = 0; + this.lastStatsTime = 0; + + console.log("[WebRTC] Disconnect completed"); + } + + private stopReconnection(): void { + this.isReconnecting = false; + this.reconnectAttempts = 0; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + cleanup(): void { + this.stopReconnection(); + if (this.isConnected || this.pc || this.ws) { + this.disconnect(true); + } + } +} diff --git a/packages/live-view/src/main.css b/packages/live-view/src/main.css new file mode 100644 index 00000000..2c9166f5 --- /dev/null +++ b/packages/live-view/src/main.css @@ -0,0 +1,22 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: #1a1a1a; + color: #ffffff; + overflow: hidden; +} + +#root { + width: 100vw; + height: 100vh; +} \ No newline at end of file diff --git a/packages/live-view/src/main.tsx b/packages/live-view/src/main.tsx new file mode 100644 index 00000000..456e91fa --- /dev/null +++ b/packages/live-view/src/main.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { AndroidLiveView } from './components/AndroidLiveView'; +import './main.css'; + +// Get configuration from environment or URL parameters +const params = new URLSearchParams(window.location.search); +const apiUrl = params.get('api') || import.meta.env.VITE_API_URL || '/api'; +const wsUrl = params.get('ws') || import.meta.env.VITE_WS_URL || `ws://${window.location.host}/ws`; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); \ No newline at end of file diff --git a/packages/live-view/src/types.ts b/packages/live-view/src/types.ts new file mode 100644 index 00000000..bd7a5e5c --- /dev/null +++ b/packages/live-view/src/types.ts @@ -0,0 +1,64 @@ +export interface Device { + serial: string; + state: string; + model?: string; + connected?: boolean; + videoWidth?: number; + videoHeight?: number; +} + +export interface LiveViewProps { + apiUrl?: string; + wsUrl?: string; + autoConnect?: boolean; + showControls?: boolean; + showDeviceList?: boolean; + onConnect?: (device: Device) => void; + onDisconnect?: () => void; + onError?: (error: Error) => void; + className?: string; +} + +export interface AndroidLiveViewProps extends LiveViewProps { + deviceSerial?: string; + showAndroidControls?: boolean; +} + +export interface ControlMessage { + type: + | "touch" + | "key" + | "scroll" + | "reset_video" + | "ping" + | "clipboard_set" + | "clipboard_get" + | number; + action?: string; + x?: number; + y?: number; + keycode?: number; + metaState?: number; + pressure?: number; + pointerId?: number; + hScroll?: number; + vScroll?: number; + text?: string; + paste?: boolean; + id?: string; + timestamp?: number; + data?: Uint8Array; +} + +export interface SignalingMessage { + type: "offer" | "answer" | "ice-candidate" | "error"; + sdp?: string; + candidate?: RTCIceCandidate; + error?: string; +} + +export interface Stats { + fps?: number; + resolution?: string; + latency?: number; +} diff --git a/packages/live-view/src/types/css-modules.d.ts b/packages/live-view/src/types/css-modules.d.ts new file mode 100644 index 00000000..f2d12bb5 --- /dev/null +++ b/packages/live-view/src/types/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module "*.module.css" { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/packages/live-view/tsconfig.json b/packages/live-view/tsconfig.json new file mode 100644 index 00000000..0d51f9dc --- /dev/null +++ b/packages/live-view/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Output */ + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "static"] +} \ No newline at end of file diff --git a/packages/live-view/vite.config.ts b/packages/live-view/vite.config.ts new file mode 100644 index 00000000..6838adfc --- /dev/null +++ b/packages/live-view/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'static', + assetsDir: 'assets', + // Generate a single HTML file with all assets inlined for embedding + rollupOptions: { + input: { + main: resolve(__dirname, 'index.html'), + }, + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/ws': { + target: 'ws://localhost:8080', + ws: true, + changeOrigin: true, + }, + }, + }, +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..90566db7 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,22 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + tslib: + specifier: ^2.8.1 + version: 2.8.1 + +packages: + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + +snapshots: + + tslib@2.8.1: {} From 47dd505f93d17f44515aa173f219a77c9faf608a Mon Sep 17 00:00:00 2001 From: Vangie Du Date: Fri, 12 Sep 2025 16:02:49 +0800 Subject: [PATCH 02/34] chore: remove unused package.json and pnpm-lock.yaml files; refactor adb_expose commands to utilize new client-server architecture --- package.json | 5 - packages/cli/Makefile | 1 + packages/cli/cmd/adb_expose.go | 44 +- packages/cli/cmd/adb_expose_list.go | 154 +--- packages/cli/cmd/adb_expose_new.go | 181 ----- packages/cli/cmd/adb_expose_start.go | 174 +---- packages/cli/cmd/adb_expose_stop.go | 54 +- packages/cli/cmd/box_list.go | 2 +- packages/cli/cmd/device_connect.go | 259 +------ .../cli/cmd/device_connect_kill_server.go | 199 ----- packages/cli/cmd/device_connect_list.go | 76 +- packages/cli/cmd/device_connect_register.go | 166 +++++ packages/cli/cmd/device_connect_server.go | 48 -- packages/cli/cmd/device_connect_unregister.go | 131 +++- packages/cli/cmd/render.go | 4 +- packages/cli/cmd/server.go | 99 ++- packages/cli/internal/adb_expose/client.go | 327 ++------- packages/cli/internal/adb_expose/commands.go | 603 ++++++++++++++++ .../internal/adb_expose/multiplex_client.go | 291 ++++++++ packages/cli/internal/adb_expose/utils.go | 181 +---- .../cli/internal/adb_expose/utils_unix.go | 82 --- .../cli/internal/adb_expose/utils_windows.go | 81 --- .../cli/internal/device_connect/client.go | 189 ----- .../cli/internal/device_connect/daemon.go | 325 --------- .../internal/device_connect/daemon_native.go | 182 ----- .../internal/device_connect/daemon_test.go | 214 ------ .../internal/device_connect/daemon_unix.go | 76 -- .../internal/device_connect/daemon_windows.go | 74 -- .../device_connect/device/connection.go | 9 +- .../cli/internal/device_connect/downloader.go | 678 ------------------ .../device_connect/downloader_test.go | 341 --------- packages/cli/internal/device_connect/kill.go | 197 ----- .../cli/internal/device_connect/server.go | 57 -- .../internal/device_connect/version_test.go | 391 ---------- packages/cli/internal/server/adb_expose.go | 523 +++++++++----- packages/cli/internal/server/auto_start.go | 148 ++++ packages/cli/internal/server/server.go | 484 ++++++++----- .../internal/server/static/adb-expose.html | 418 +++++++++++ .../cli/internal/server/static/favicon.svg | 11 + .../cli/internal/server/static/index.html | 139 +++- .../cli/internal/server/static/live-view.html | 71 ++ .../cli/internal/server/static_handlers.go | 255 +++++++ packages/cli/internal/server/ui_handlers.go | 399 ----------- packages/cli/internal/server/version.go | 43 ++ pnpm-lock.yaml | 22 - 45 files changed, 3123 insertions(+), 5285 deletions(-) delete mode 100644 package.json delete mode 100644 packages/cli/cmd/adb_expose_new.go delete mode 100644 packages/cli/cmd/device_connect_kill_server.go create mode 100644 packages/cli/cmd/device_connect_register.go delete mode 100644 packages/cli/cmd/device_connect_server.go create mode 100644 packages/cli/internal/adb_expose/commands.go create mode 100644 packages/cli/internal/adb_expose/multiplex_client.go delete mode 100644 packages/cli/internal/adb_expose/utils_unix.go delete mode 100644 packages/cli/internal/adb_expose/utils_windows.go delete mode 100644 packages/cli/internal/device_connect/client.go delete mode 100644 packages/cli/internal/device_connect/daemon.go delete mode 100644 packages/cli/internal/device_connect/daemon_native.go delete mode 100644 packages/cli/internal/device_connect/daemon_test.go delete mode 100644 packages/cli/internal/device_connect/daemon_unix.go delete mode 100644 packages/cli/internal/device_connect/daemon_windows.go delete mode 100644 packages/cli/internal/device_connect/downloader.go delete mode 100644 packages/cli/internal/device_connect/downloader_test.go delete mode 100644 packages/cli/internal/device_connect/kill.go delete mode 100644 packages/cli/internal/device_connect/server.go delete mode 100644 packages/cli/internal/device_connect/version_test.go create mode 100644 packages/cli/internal/server/auto_start.go create mode 100644 packages/cli/internal/server/static/adb-expose.html create mode 100644 packages/cli/internal/server/static/favicon.svg create mode 100644 packages/cli/internal/server/static/live-view.html create mode 100644 packages/cli/internal/server/static_handlers.go delete mode 100644 packages/cli/internal/server/ui_handlers.go create mode 100644 packages/cli/internal/server/version.go delete mode 100644 pnpm-lock.yaml diff --git a/package.json b/package.json deleted file mode 100644 index 26a3c1d1..00000000 --- a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "devDependencies": { - "tslib": "^2.8.1" - } -} diff --git a/packages/cli/Makefile b/packages/cli/Makefile index 575c968c..206f4db2 100644 --- a/packages/cli/Makefile +++ b/packages/cli/Makefile @@ -75,6 +75,7 @@ download-scrcpy-server: ## Download scrcpy-server.jar # Build binary for a single platform binary: build-deps ## Build binary for the current platform (GOOS/GOARCH) @echo "Building $(BINARY_NAME) binary ($(GOOS)/$(GOARCH))..." + @echo "Note: live-view static files will be embedded in the binary" CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build $(LDFLAGS) -o $(BINARY_NAME) $(MAIN_FILE) @echo "Binary built: $(BINARY_NAME)" diff --git a/packages/cli/cmd/adb_expose.go b/packages/cli/cmd/adb_expose.go index 08e0176d..00fc81cd 100644 --- a/packages/cli/cmd/adb_expose.go +++ b/packages/cli/cmd/adb_expose.go @@ -83,6 +83,8 @@ func NewAdbExposeStartCommand() *cobra.Command { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return completeBoxIDs(cmd, args, toComplete) }, + SilenceUsage: true, // Don't show usage on error + SilenceErrors: true, // Don't show errors (we handle them ourselves) } cmd.Flags().IntVarP(&opts.LocalPort, "port", "p", 0, "Local port to bind to (default: auto-find available port starting from 5555)") @@ -103,6 +105,8 @@ func NewAdbExposeStopCommand() *cobra.Command { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return completeBoxIDs(cmd, args, toComplete) }, + SilenceUsage: true, // Don't show usage on error + SilenceErrors: true, // Don't show errors (we handle them ourselves) } return cmd } @@ -111,11 +115,13 @@ func NewAdbExposeListCommand() *cobra.Command { opts := &AdbExposeListOptions{} cmd := &cobra.Command{ Use: "list", - Short: "List all running adb-expose processes", + Short: "List all exposed ADB ports", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return ExecuteAdbExposeList(cmd, opts) }, + SilenceUsage: true, // Don't show usage on error + SilenceErrors: true, // Don't show errors (we handle them ourselves) } cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "table", "Output format (table|json)") @@ -142,19 +148,14 @@ func ExecuteAdbExposeInteractive(cmd *cobra.Command, opts *AdbExposeOptions) err return fmt.Errorf("interactive mode not available in daemon process") } - // Get current exposures without running cleanup logic - infos, err := adb_expose.ListPidFiles() - if err != nil { - return fmt.Errorf("failed to list current exposures: %v", err) - } - - // Only show current exposures section if there are any - if len(infos) > 0 { - fmt.Println("Current ADB port exposures:") - fmt.Println("============================") - printAdbExposeTable(infos) - fmt.Println() + // Use the new client-server architecture to list current exposures + fmt.Println("Current ADB port exposures:") + fmt.Println("============================") + if err := adb_expose.ListCommand(""); err != nil { + // If server is not running, just show a message + fmt.Println("ADB Expose server is not running") } + fmt.Println() // Get available boxes sdkClient, err := client.NewClientFromProfile() @@ -172,22 +173,11 @@ func ExecuteAdbExposeInteractive(cmd *cobra.Command, opts *AdbExposeOptions) err return nil } - // Filter running Android boxes and exclude already exposed ones + // Filter running Android boxes var availableBoxes []client.BoxInfo - exposedBoxIDs := make(map[string]bool) - - // Use the infos variable we already got above - for _, info := range infos { - if adb_expose.IsProcessAlive(info.Pid) { - exposedBoxIDs[info.BoxID] = true - } - } - for _, box := range boxes { if box.Status == "running" && strings.HasPrefix(box.Type, "android") { - if !exposedBoxIDs[box.ID] { - availableBoxes = append(availableBoxes, box) - } + availableBoxes = append(availableBoxes, box) } } @@ -283,7 +273,7 @@ func ExecuteAdbExposeInteractive(cmd *cobra.Command, opts *AdbExposeOptions) err // ExecuteAdbExposeStop stops adb-expose processes for a specific box // This function is now implemented in adb_expose_stop.go -// ExecuteAdbExposeList lists all running adb-expose processes +// ExecuteAdbExposeList lists all exposed ADB ports // This function is now implemented in adb_expose_list.go func boxValid(boxID string) bool { diff --git a/packages/cli/cmd/adb_expose_list.go b/packages/cli/cmd/adb_expose_list.go index ee575614..2b9a7fcf 100644 --- a/packages/cli/cmd/adb_expose_list.go +++ b/packages/cli/cmd/adb_expose_list.go @@ -1,160 +1,12 @@ package cmd import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "strconv" - "strings" - "github.com/babelcloud/gbox/packages/cli/internal/adb_expose" "github.com/spf13/cobra" ) -// ExecuteAdbExposeList lists all running adb-expose processes +// ExecuteAdbExposeList lists all exposed ADB ports using the new client-server architecture func ExecuteAdbExposeList(cmd *cobra.Command, opts *AdbExposeListOptions) error { - // Step 1: Find all running gbox adb-expose processes (cross-platform, best effort) - psCmd := exec.Command("ps", "aux") - psOut, err := psCmd.Output() - if err != nil { - return fmt.Errorf("failed to run ps aux: %v", err) - } - lines := strings.Split(string(psOut), "\n") - var runningPids = make(map[int]bool) - for _, line := range lines { - if strings.Contains(line, "gbox adb-expose") && !strings.Contains(line, "grep") { - // ignore gbox adb-expose list process itself - if strings.Contains(line, "gbox adb-expose list") { - continue - } - fields := strings.Fields(line) - if len(fields) > 1 { - pid, err := strconv.Atoi(fields[1]) - if err == nil { - runningPids[pid] = true - } - } - } - } - // Step 2: List all pid files (registered adb-exposes) - infos, err := adb_expose.ListPidFiles() - if err != nil { - return err - } - registeredPids := make(map[int]adb_expose.PidInfo) - for _, info := range infos { - registeredPids[info.Pid] = info - } - // Step 3: Check for running processes not in pid files - for pid := range runningPids { - if _, ok := registeredPids[pid]; !ok { - fmt.Printf("[WARN] Found running adb-expose process (pid=%d) not in registry. If you want to stop it, run: gbox adb-expose stop \n\n", pid) - } - } - // Step 4: Check for pid files whose process is not running, and clean up - for pid, info := range registeredPids { - if !runningPids[pid] && !adb_expose.IsProcessAlive(pid) { - fmt.Printf("[CLEANUP] Removing stale pid file for dead process (pid=%d, boxId=%s, localPorts=%v)\n", pid, info.BoxID, info.LocalPorts) - for _, lp := range info.LocalPorts { - adb_expose.RemovePidFile(info.BoxID, lp) - adb_expose.RemoveLogFile(info.BoxID, lp) - } - } - } - // Step 5: For those pid files exist and process is running, check the box status, if the box is not running, clean up the pid file and kill the process - for pid, info := range registeredPids { - if runningPids[pid] && adb_expose.IsProcessAlive(pid) { - if !boxValid(info.BoxID) { - fmt.Printf("[CLEANUP] Box %s is not running, killing adb-expose process (pid=%d) and removing pid file(s)\n", info.BoxID, pid) - proc, err := os.FindProcess(pid) - if err == nil { - proc.Kill() - } - for _, lp := range info.LocalPorts { - adb_expose.RemovePidFile(info.BoxID, lp) - adb_expose.RemoveLogFile(info.BoxID, lp) - } - } - } - } - - // Step 6: Print the current valid adb-exposes - updatedInfos, err := adb_expose.ListPidFiles() - if err != nil { - return fmt.Errorf("failed to list pid files after cleanup: %v", err) - } - - // Output based on format - if opts.OutputFormat == "json" { - printAdbExposeJSON(updatedInfos) - } else { - printAdbExposeTable(updatedInfos) - } - return nil -} - -// printAdbExposeTable prints the ADB expose table in a formatted way -func printAdbExposeTable(infos []adb_expose.PidInfo) { - if len(infos) == 0 { - fmt.Println("No ADB port exposures found") - return - } - - fmt.Printf("| %-8s | %-36s | %-10s | %-8s | %-20s |\n", "PID", "BoxID", "Port", "Status", "StartedAt") - fmt.Println("|----------|--------------------------------------|------------|----------|----------------------|") - for _, info := range infos { - status := "Dead" - if adb_expose.IsProcessAlive(info.Pid) { - status = "Alive" - } - for i := 0; i < len(info.LocalPorts); i++ { - fmt.Printf("| %-8d | %-36s | %-10d | %-8s | %-20s |\n", info.Pid, info.BoxID, info.LocalPorts[i], status, info.StartedAt.Format("2006-01-02 15:04:05")) - } - } -} - -// printAdbExposeJSON prints the ADB expose information in JSON format -func printAdbExposeJSON(infos []adb_expose.PidInfo) { - // Debug: check if infos is nil or empty - if infos == nil { - fmt.Println("[]") - return - } - - type AdbExposeInfo struct { - PID int `json:"pid"` - BoxID string `json:"boxId"` - LocalPorts []int `json:"localPorts"` - Status string `json:"status"` - StartedAt string `json:"startedAt"` - } - - var jsonData []AdbExposeInfo - for _, info := range infos { - status := "Dead" - if adb_expose.IsProcessAlive(info.Pid) { - status = "Alive" - } - - jsonInfo := AdbExposeInfo{ - PID: info.Pid, - BoxID: info.BoxID, - LocalPorts: info.LocalPorts, - Status: status, - StartedAt: info.StartedAt.Format("2006-01-02T15:04:05Z"), - } - jsonData = append(jsonData, jsonInfo) - } - - // Ensure we always output a valid JSON array, even if empty - jsonBytes, err := json.MarshalIndent(jsonData, "", " ") - if err != nil { - fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err) - // Fallback to empty array if marshaling fails - fmt.Println("[]") - return - } - - fmt.Println(string(jsonBytes)) + // Use the new client-server architecture + return adb_expose.ListCommand(opts.OutputFormat) } diff --git a/packages/cli/cmd/adb_expose_new.go b/packages/cli/cmd/adb_expose_new.go deleted file mode 100644 index 12b1f535..00000000 --- a/packages/cli/cmd/adb_expose_new.go +++ /dev/null @@ -1,181 +0,0 @@ -package cmd - -import ( - "fmt" - "strconv" - "strings" - - "github.com/babelcloud/gbox/packages/cli/internal/daemon" - "github.com/spf13/cobra" -) - -// NewAdbExposeCommand creates the adb-expose command that uses the unified server -func NewAdbExposeCommandNew() *cobra.Command { - var ( - localPort int - remotePort int - device string - protocol string - list bool - remove bool - ) - - cmd := &cobra.Command{ - Use: "adb-expose", - Short: "Expose ADB ports (similar to adb forward)", - Long: `Expose ADB ports from Android device to local machine. -This is similar to 'adb forward' but managed by the gbox server. - -The gbox server will be automatically started if not running.`, - RunE: func(cmd *cobra.Command, args []string) error { - // List all forwards - if list { - return listForwards() - } - - // Remove forward - if remove { - if localPort == 0 { - return fmt.Errorf("--local-port required for --remove") - } - return removeForward(device, localPort, remotePort) - } - - // Add forward - if localPort == 0 || remotePort == 0 { - return fmt.Errorf("both --local-port and --remote-port are required") - } - - return addForward(device, localPort, remotePort, protocol) - }, - Example: ` # Forward port 8080 from device to local port 8080 - gbox adb-expose -l 8080 -r 8080 - - # Forward with specific device - gbox adb-expose -d emulator-5554 -l 8080 -r 8080 - - # List all forwards - gbox adb-expose --list - - # Remove a forward - gbox adb-expose --remove -l 8080`, - } - - flags := cmd.Flags() - flags.IntVarP(&localPort, "local-port", "l", 0, "Local port to forward to") - flags.IntVarP(&remotePort, "remote-port", "r", 0, "Remote port on device") - flags.StringVarP(&device, "device", "d", "", "Target device serial") - flags.StringVarP(&protocol, "protocol", "p", "tcp", "Protocol (tcp or unix)") - flags.BoolVar(&list, "list", false, "List all port forwards") - flags.BoolVar(&remove, "remove", false, "Remove a port forward") - - return cmd -} - -func addForward(device string, localPort, remotePort int, protocol string) error { - req := map[string]interface{}{ - "device_serial": device, - "local_port": localPort, - "remote_port": remotePort, - "protocol": protocol, - } - - var resp map[string]interface{} - if err := daemon.DefaultManager.CallAPI("POST", "/api/adb-expose/start", req, &resp); err != nil { - return fmt.Errorf("failed to add forward: %v", err) - } - - if success, ok := resp["success"].(bool); ok && success { - fmt.Printf("Port forward added: %d -> %d\n", localPort, remotePort) - } else { - return fmt.Errorf("failed to add forward: %v", resp["error"]) - } - - return nil -} - -func removeForward(device string, localPort, remotePort int) error { - req := map[string]interface{}{ - "device_serial": device, - "local_port": localPort, - "remote_port": remotePort, - } - - var resp map[string]interface{} - if err := daemon.DefaultManager.CallAPI("POST", "/api/adb-expose/stop", req, &resp); err != nil { - return fmt.Errorf("failed to remove forward: %v", err) - } - - if success, ok := resp["success"].(bool); ok && success { - fmt.Println("Port forward removed") - } else { - return fmt.Errorf("failed to remove forward: %v", resp["error"]) - } - - return nil -} - -func listForwards() error { - var resp map[string]interface{} - if err := daemon.DefaultManager.CallAPI("GET", "/api/adb-expose/list", nil, &resp); err != nil { - return fmt.Errorf("failed to list forwards: %v", err) - } - - // Display all forwards - if forwards, ok := resp["forwards"].([]interface{}); ok && len(forwards) > 0 { - fmt.Println("Active port forwards:") - for _, f := range forwards { - if forward, ok := f.(map[string]interface{}); ok { - device := forward["device_serial"].(string) - local := forward["local"].(string) - remote := forward["remote"].(string) - - // Parse ports if available - localPort := "" - remotePort := "" - - if lp, ok := forward["local_port"].(float64); ok { - localPort = strconv.Itoa(int(lp)) - } else { - // Extract from string like "tcp:8080" - parts := strings.Split(local, ":") - if len(parts) > 1 { - localPort = parts[1] - } - } - - if rp, ok := forward["remote_port"].(float64); ok { - remotePort = strconv.Itoa(int(rp)) - } else { - // Extract from string like "tcp:8080" - parts := strings.Split(remote, ":") - if len(parts) > 1 { - remotePort = parts[1] - } - } - - fmt.Printf(" %s: %s -> %s\n", device, localPort, remotePort) - } - } - } else { - fmt.Println("No active port forwards") - } - - // Display managed forwards - if managed, ok := resp["managed"].([]interface{}); ok && len(managed) > 0 { - fmt.Println("\nManaged by gbox server:") - for _, m := range managed { - if forward, ok := m.(map[string]interface{}); ok { - device := forward["device_serial"].(string) - localPort := int(forward["local_port"].(float64)) - remotePort := int(forward["remote_port"].(float64)) - protocol := forward["protocol"].(string) - - fmt.Printf(" %s: %s:%d -> %s:%d\n", - device, "tcp", localPort, protocol, remotePort) - } - } - } - - return nil -} \ No newline at end of file diff --git a/packages/cli/cmd/adb_expose_start.go b/packages/cli/cmd/adb_expose_start.go index 969e950a..7ae85603 100644 --- a/packages/cli/cmd/adb_expose_start.go +++ b/packages/cli/cmd/adb_expose_start.go @@ -2,189 +2,29 @@ package cmd import ( "fmt" - "log" - "net" - "os" - "os/signal" - "sync" - "syscall" - "time" - "github.com/babelcloud/gbox/packages/cli/config" "github.com/babelcloud/gbox/packages/cli/internal/adb_expose" - "github.com/babelcloud/gbox/packages/cli/internal/profile" "github.com/spf13/cobra" ) -// ExecuteAdbExpose runs the adb-expose logic +// ExecuteAdbExpose runs the adb-expose logic using the new client-server architecture func ExecuteAdbExpose(cmd *cobra.Command, opts *AdbExposeOptions, args []string) error { if opts.BoxID == "" && len(args) > 0 { opts.BoxID = args[0] } - if opts.BoxID == "" || !boxValid(opts.BoxID) { - return fmt.Errorf("the box you specified is not valid, check --help for how to add it or using 'gbox box list' to check") + if opts.BoxID == "" { + return fmt.Errorf("box ID is required. Usage: gbox adb-expose start ") } // Determine local port to use localPort := opts.LocalPort if localPort == 0 { - // Auto-find available port starting from 5555 - var err error - localPort, err = findAvailablePort(5555) - if err != nil { - return fmt.Errorf("failed to find available port: %v", err) - } - log.Printf("Auto-selected local port: %d", localPort) - } else { - // Check if specified port is available - if localPort < 1 || localPort > 65535 { - return fmt.Errorf("invalid local port %d: port must be between 1 and 65535", localPort) - } - - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort)) - if err != nil { - portInfo := getPortUsageInfo(localPort) - if portInfo != "" { - return fmt.Errorf("the port %d is already in use by: %s", localPort, portInfo) - } - return fmt.Errorf("the port %d is not available: %v", localPort, err) - } - listener.Close() + localPort = 5555 // Default port } // ADB always uses port 5555 on the remote side remotePort := 5555 - // Get API Key with priority: GBOX_API_KEY env var > profile - apiKey, err := profile.Default.GetEffectiveAPIKey() - if err != nil { - return fmt.Errorf("failed to get API key: %v", err) - } - - logPath := fmt.Sprintf("%s/gbox-adb-expose-%s-%d.log", config.GetGboxHome(), opts.BoxID, localPort) - if shouldReturn, err := adb_expose.DaemonizeIfNeeded(opts.Foreground, logPath, opts.BoxID, true); shouldReturn { - return err - } - - // Write pid file - if err := adb_expose.WritePidFile(opts.BoxID, []int{localPort}, []int{remotePort}); err != nil { - return fmt.Errorf("failed to write pid file: %v", err) - } - - // Clean up pid and log files on exit - defer func() { - adb_expose.RemovePidFile(opts.BoxID, localPort) - adb_expose.RemoveLogFile(opts.BoxID, localPort) - }() - - // Signal handling for cleanup - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-sigCh - adb_expose.RemovePidFile(opts.BoxID, localPort) - adb_expose.RemoveLogFile(opts.BoxID, localPort) - os.Exit(0) - }() - - // Get effective base URL for connection - effectiveBaseURL := profile.Default.GetEffectiveBaseURL() - - // Connect to websocket - portForwardConfig := adb_expose.Config{ - APIKey: apiKey, - BoxID: opts.BoxID, - GboxURL: effectiveBaseURL, - TargetPorts: []int{remotePort}, - } - - retryInterval := 3 * time.Second - log.Printf("Starting adb-expose: local port %d <-> remote ADB port %d (auto-reconnect enabled)", localPort, remotePort) - - for { - // Listen on local port - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localPort)) - if err != nil { - return fmt.Errorf("failed to listen on port %d: %v", localPort, err) - } - - // Connect to websocket with retry (max 3 attempts) - var client *adb_expose.MultiplexClient - var connectErr error - for attempt := 1; attempt <= 3; attempt++ { - client, connectErr = adb_expose.ConnectWebSocket(portForwardConfig) - if connectErr == nil { - break - } - if attempt < 3 { - log.Printf("adb-expose connection attempt %d failed: %v, retrying...", attempt, connectErr) - time.Sleep(5 * time.Second) - } - } - if connectErr != nil { - listener.Close() - return fmt.Errorf("failed to connect to adb-expose after 3 attempts: %v", connectErr) - } - - // Concurrency & Reconnection Control Logic - reconnectCh := make(chan struct{}) - stopAcceptCh := make(chan struct{}) - - // Start the main loop for the WebSocket client. - go func() { - if err := client.Run(); err != nil { - log.Printf("client run error: %v", err) - } - close(reconnectCh) - }() - - acceptDone := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(1) - - // Start the local port listener goroutine. - go func() { - defer wg.Done() - for { - select { - case <-stopAcceptCh: - return - default: - localConn, err := listener.Accept() - if err != nil { - log.Printf("accept error: %v", err) - time.Sleep(time.Second) - continue - } - go adb_expose.HandleLocalConnWithClient(localConn, client, remotePort) - } - } - }() - - // Wait for all accept goroutines to exit - go func() { - wg.Wait() - close(acceptDone) - }() - - log.Printf("adb port is exposed, you can connect to the device by `adb connect 127.0.0.1:%d`", localPort) - - // Main flow waits for: - select { - case <-reconnectCh: - log.Println("websocket disconnected, will attempt to reconnect...") - close(stopAcceptCh) - listener.Close() // force accept goroutine to exit - <-acceptDone - client.Close() - log.Printf("Reconnecting in %v...", retryInterval) - time.Sleep(retryInterval) - continue // retry loop - case <-acceptDone: - log.Println("accept loop ended") - listener.Close() - client.Close() - return nil - } - } -} + // Use the new client-server architecture + return adb_expose.StartCommand(opts.BoxID, []int{localPort}, []int{remotePort}, opts.Foreground) +} \ No newline at end of file diff --git a/packages/cli/cmd/adb_expose_stop.go b/packages/cli/cmd/adb_expose_stop.go index defe20fb..1104eb05 100644 --- a/packages/cli/cmd/adb_expose_stop.go +++ b/packages/cli/cmd/adb_expose_stop.go @@ -2,64 +2,18 @@ package cmd import ( "fmt" - "os" - "syscall" "github.com/babelcloud/gbox/packages/cli/internal/adb_expose" "github.com/spf13/cobra" ) -// ExecuteAdbExposeStop stops adb-expose processes for a specific box +// ExecuteAdbExposeStop stops adb-expose processes for a specific box using the new client-server architecture func ExecuteAdbExposeStop(cmd *cobra.Command, opts *AdbExposeStopOptions, args []string) error { boxID := args[0] if boxID == "" { return fmt.Errorf("box ID is required. Usage: gbox adb-expose stop ") } - // Find all running adb-expose processes for this box - infos, err := adb_expose.ListPidFiles() - if err != nil { - return fmt.Errorf("failed to list pid files: %v", err) - } - - var foundProcesses []int - for _, info := range infos { - if info.BoxID == boxID { - foundProcesses = append(foundProcesses, info.Pid) - } - } - - if len(foundProcesses) == 0 { - return fmt.Errorf("no running adb-expose processes found for box %s", boxID) - } - - // Stop all processes for this box - for _, pid := range foundProcesses { - proc, err := os.FindProcess(pid) - if err != nil { - fmt.Printf("Warning: failed to find process %d: %v\n", pid, err) - continue - } - - // Find the specific process info for better output - var processInfo *adb_expose.PidInfo - for _, info := range infos { - if info.Pid == pid { - processInfo = &info - break - } - } - - if processInfo != nil { - fmt.Printf("Stopping adb-expose process %d for box %s (port %d)\n", pid, boxID, processInfo.LocalPorts[0]) - } - - err = proc.Signal(syscall.SIGTERM) - if err != nil { - fmt.Printf("Warning: failed to stop process %d: %v\n", pid, err) - } - } - - fmt.Printf("Successfully stopped all adb-expose processes for box %s\n", boxID) - return nil -} + // Use the new client-server architecture + return adb_expose.StopCommand(boxID) +} \ No newline at end of file diff --git a/packages/cli/cmd/box_list.go b/packages/cli/cmd/box_list.go index 09bdfe73..f58a121e 100644 --- a/packages/cli/cmd/box_list.go +++ b/packages/cli/cmd/box_list.go @@ -136,6 +136,6 @@ func printResponse(resp interface{}, outputFormat string) error { {Header: "STATUS", Key: "status"}, } - renderTable(columns, data) + RenderTable(columns, data) return nil } diff --git a/packages/cli/cmd/device_connect.go b/packages/cli/cmd/device_connect.go index 2f5a6bc4..207aea43 100644 --- a/packages/cli/cmd/device_connect.go +++ b/packages/cli/cmd/device_connect.go @@ -2,16 +2,8 @@ package cmd import ( "fmt" - "os" "os/exec" - "os/signal" - "path/filepath" - "syscall" - "github.com/babelcloud/gbox/packages/cli/config" - "github.com/babelcloud/gbox/packages/cli/internal/daemon" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect" - "github.com/babelcloud/gbox/packages/cli/internal/profile" "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -89,43 +81,24 @@ func printFrpcInstallationHint() { fmt.Println() } -type DeviceConnectOptions struct { - DeviceID string - Background bool - UseNative bool // Use native Go implementation instead of external binary -} - -// Global client instance -var deviceClient *device_connect.Client - -// getDeviceClient returns the global device client, initializing it if needed -func getDeviceClient() *device_connect.Client { - if deviceClient == nil { - deviceClient = device_connect.NewClient(device_connect.DefaultURL) - } - return deviceClient -} +// Note: Device client functionality has been moved to daemon.DefaultManager +// All device operations now go through the unified server API func NewDeviceConnectCommand() *cobra.Command { - opts := &DeviceConnectOptions{} - cmd := &cobra.Command{ - Use: "device-connect [command] [flags]", + Use: "device-connect [command]", Short: "Manage remote connections for local Android development devices", Long: `Manage remote connections for local Android development devices. This command allows you to securely connect Android devices (emulators or physical devices) to remote cloud services for remote access and debugging.`, RunE: func(cmd *cobra.Command, args []string) error { - return ExecuteDeviceConnect(cmd, opts, args) + return cmd.Help() }, - Example: ` # Interactively select a device to connect - gbox device-connect + Example: ` # Register a device for remote access + gbox device-connect register - # Connect to specific device - gbox device-connect --device abc123xyz456-usb - - # Connect device in background - gbox device-connect --device abc789pqr012-ip --background + # Register specific device + gbox device-connect register abc123xyz456-usb # List all available devices gbox device-connect ls @@ -134,229 +107,17 @@ to remote cloud services for remote access and debugging.`, gbox device-connect ls --format json # Unregister specific device - gbox device-connect unregister abc789pqr012-ip - - # Stop the device proxy service - gbox device-connect kill-server`, + gbox device-connect unregister abc789pqr012-ip`, } - flags := cmd.Flags() - flags.StringVarP(&opts.DeviceID, "device", "d", "", "Specify the Android device ID to connect to") - flags.BoolVarP(&opts.Background, "background", "b", false, "Run connection in background") - flags.BoolVar(&opts.UseNative, "native", false, "Use native Go implementation (experimental)") - cmd.AddCommand( + NewDeviceConnectRegisterCommand(), NewDeviceConnectListCommand(), NewDeviceConnectUnregisterCommand(), - NewDeviceConnectKillServerCommand(), - NewDeviceConnectServerCommand(), ) return cmd } -func ExecuteDeviceConnect(cmd *cobra.Command, opts *DeviceConnectOptions, args []string) error { - if !checkAdbInstalled() { - printAdbInstallationHint() - return fmt.Errorf("ADB is not installed or not in your PATH. Please install ADB and try again.") - } - - // Always use the unified server (like adb start-server) - // The server will be auto-started if not running - - // Note: Legacy mode with external binaries is being phased out - // All functionality now goes through the unified gbox server - - // The actual device connection will happen via HTTP API calls - // to the server, which will be started automatically by the daemon manager - - if opts.DeviceID == "" { - return runInteractiveDeviceSelection(opts) - } - return connectToDevice(opts.DeviceID, opts) -} - -func isServiceRunning() (bool, error) { - // First check if PID file exists - deviceProxyHome := config.GetDeviceProxyHome() - pidFile := filepath.Join(deviceProxyHome, "device-proxy.pid") - - if _, err := os.Stat(pidFile); os.IsNotExist(err) { - return false, nil - } - - // Read PID from file - pidBytes, err := os.ReadFile(pidFile) - if err != nil { - return false, nil - } - - var pid int - if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { - return false, nil - } - - // Check if process is still running - if err := exec.Command("kill", "-0", fmt.Sprintf("%d", pid)).Run(); err != nil { - // Process is not running, remove PID file - os.Remove(pidFile) - return false, nil - } - - // Try to check service status via API - client := getDeviceClient() - running, onDemandEnabled, err := client.IsServiceRunning() - if err != nil { - // If API check fails, assume service is running (we have a valid PID) - return true, nil - } - - // Check if onDemandEnabled is false and warn user - if running && !onDemandEnabled { - fmt.Println("Warning: Reusing existing device-proxy service that does not have on-demand registration enabled.") - fmt.Println("All devices will be automatically registered for remote access.") - fmt.Println("If you don't want this behavior, either:") - fmt.Println(" - Stop the existing service and restart with ENABLE_DEVICE_REGISTER_ON_DEMAND=true") - fmt.Println(" - Use 'gbox device-connect kill-server' to stop the current service") - fmt.Println() - } - - return running, nil -} - -func runInteractiveDeviceSelection(opts *DeviceConnectOptions) error { - // Use daemon manager to call API - var response struct { - Success bool `json:"success"` - Devices []map[string]interface{} `json:"devices"` - } - - if err := daemon.DefaultManager.CallAPI("GET", "/api/devices", nil, &response); err != nil { - return fmt.Errorf("failed to get available devices: %v", err) - } - - if !response.Success { - return fmt.Errorf("failed to get devices from server") - } - - devices := response.Devices - if len(devices) == 0 { - fmt.Println("No Android devices found.") - fmt.Println() - printDeveloperModeHint() - return nil - } - fmt.Println() - fmt.Println("Select a device to register for remote access:") - fmt.Println() - printDeveloperModeHint() - fmt.Println() - for i, device := range devices { - status := "Not Registered" - statusColor := color.New(color.Faint) - - // Extract device info from map - serialNo := device["ro.serialno"].(string) - connectionType := device["connectionType"].(string) - isRegistered, _ := device["isRegistrable"].(bool) - - if isRegistered { - status = "Registered" - statusColor = color.New(color.FgGreen) - } - - model := "Unknown" - if m, ok := device["ro.product.model"].(string); ok { - model = m - } - - manufacturer := "" - if mfr, ok := device["ro.product.manufacturer"].(string); ok { - manufacturer = mfr - } - - fmt.Printf("%d. %s (%s, %s) - %s [%s]\n", - i+1, - color.New(color.FgCyan).Sprint(serialNo+"-"+connectionType), - model, - connectionType, - manufacturer, - statusColor.Sprint(status)) - } - fmt.Println() - fmt.Print("Enter a number: ") - var choice int - fmt.Scanf("%d", &choice) - if choice < 1 || choice > len(devices) { - return fmt.Errorf("invalid selection: %d", choice) - } - - selectedDevice := devices[choice-1] - deviceID := selectedDevice["id"].(string) - return connectToDevice(deviceID, opts) -} - -func connectToDevice(deviceID string, opts *DeviceConnectOptions) error { - // Register device via daemon API - req := map[string]string{"deviceId": deviceID} - var resp map[string]interface{} - - if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/register", req, &resp); err != nil { - return fmt.Errorf("failed to register device: %v", err) - } - - if success, ok := resp["success"].(bool); !ok || !success { - return fmt.Errorf("failed to register device: %v", resp["error"]) - } - - fmt.Printf("Establishing remote connection for device %s...\n", deviceID) - - fmt.Printf("Connection established successfully!\n") - - // Display local Web UI URL - fmt.Printf("\n📱 View and control your device at: %s\n", color.CyanString("http://localhost:29888")) - fmt.Printf(" This is the local live-view interface for device control\n") - - // Get and display devices URL for the current profile - pm := profile.NewProfileManager() - if err := pm.Load(); err == nil { - if devicesURL, err := pm.GetDevicesURL(); err == nil { - fmt.Printf("\n☁️ Remote access available at: %s\n", color.CyanString(devicesURL)) - } - } - - if opts.Background { - fmt.Println("(Running in background. Use 'gbox device-connect unregister' to stop.)") - return nil - } - - fmt.Printf("(Running in foreground. Press %s to disconnect.)\n", color.New(color.FgYellow, color.Bold).Sprint("Ctrl+C")) - - // Wait for interrupt signal - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - <-sigChan - fmt.Printf("Disconnecting device %s...\n", deviceID) - - // Unregister the device via daemon API - req = map[string]string{"deviceId": deviceID} - if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/unregister", req, nil); err != nil { - fmt.Printf("Warning: failed to unregister device: %v\n", err) - } - - return nil -} - -// executeKillServer calls the existing kill-server functionality -func executeKillServer() error { - opts := &DeviceConnectKillServerOptions{ - Force: false, - All: false, - } - // Create a dummy command for ExecuteDeviceConnectKillServer - // We only need this for the function signature, the actual cmd parameter is not used in the implementation - return ExecuteDeviceConnectKillServer(nil, opts) -} diff --git a/packages/cli/cmd/device_connect_kill_server.go b/packages/cli/cmd/device_connect_kill_server.go deleted file mode 100644 index 76b1e119..00000000 --- a/packages/cli/cmd/device_connect_kill_server.go +++ /dev/null @@ -1,199 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - - "github.com/babelcloud/gbox/packages/cli/config" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect" - "github.com/spf13/cobra" -) - -type DeviceConnectKillServerOptions struct { - Force bool - All bool -} - -func NewDeviceConnectKillServerCommand() *cobra.Command { - opts := &DeviceConnectKillServerOptions{} - - cmd := &cobra.Command{ - Use: "kill-server [flags]", - Aliases: []string{"kill"}, - Short: "Stop the device proxy service", - Long: "Stop the device proxy service running on port 19925.", - Example: ` # Stop the device proxy service gracefully (PID file only) - gbox device-connect kill-server - - # Force kill the device proxy service (PID file only) - gbox device-connect kill-server --force - - # Kill all device proxy processes (port and name detection) - gbox device-connect kill-server --all - - # Force kill all device proxy processes - gbox device-connect kill-server --all --force`, - RunE: func(cmd *cobra.Command, args []string) error { - return ExecuteDeviceConnectKillServer(cmd, opts) - }, - } - - flags := cmd.Flags() - flags.BoolVarP(&opts.Force, "force", "f", false, "Force kill the service process") - flags.BoolVarP(&opts.All, "all", "a", false, "Kill all device proxy processes (not just PID file)") - - return cmd -} - -func ExecuteDeviceConnectKillServer(cmd *cobra.Command, opts *DeviceConnectKillServerOptions) error { - // Check if PID file exists first - deviceProxyHome := config.GetDeviceProxyHome() - pidFile := filepath.Join(deviceProxyHome, "device-proxy.pid") - - pidFileExists := false - var pidFromFile int - if _, err := os.Stat(pidFile); err == nil { - pidFileExists = true - // Try to read PID from file - if pidBytes, err := os.ReadFile(pidFile); err == nil { - fmt.Sscanf(string(pidBytes), "%d", &pidFromFile) - } - } - - // Check if any device-proxy processes are currently running - hasRunningProcesses := false - if opts.All { - portProcesses, _ := device_connect.FindProcessesOnPort(device_connect.DefaultPort) - nameProcesses, _ := device_connect.FindGboxDeviceProxyProcesses() - - // Check if there are any actual device-proxy processes - for _, pid := range portProcesses { - if device_connect.IsDeviceProxyProcess(pid) { - hasRunningProcesses = true - break - } - } - for range nameProcesses { - hasRunningProcesses = true - break - } - } else { - // When not using --all, only check if the PID from file is still running - if pidFileExists && pidFromFile > 0 { - // Check if the process is still running - if err := exec.Command("kill", "-0", fmt.Sprintf("%d", pidFromFile)).Run(); err == nil { - hasRunningProcesses = true - } - } - } - - // If no processes are running and no PID file exists, report that service is not running - if !hasRunningProcesses && !pidFileExists { - fmt.Println("Device proxy service is not running.") - return nil - } - - fmt.Println("Stopping device proxy service...") - - // Method 1: Always try to kill processes using PID file - if pidFileExists { - // PID file exists, try to kill the process - pidBytes, err := os.ReadFile(pidFile) - if err == nil { - var pid int - if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err == nil { - if err := device_connect.KillProcess(pid, opts.Force); err == nil { - fmt.Printf("Killed process %d from PID file\n", pid) - } else { - fmt.Printf("Warning: failed to kill process %d from PID file: %v\n", pid, err) - } - } - } - // Remove PID file regardless of success - os.Remove(pidFile) - } - - // Only use port and name-based killing when --all flag is set - if opts.All { - // Method 2: Find and kill processes by port, but only if they are device-proxy processes - portProcesses, err := device_connect.FindProcessesOnPort(device_connect.DefaultPort) - if err == nil && len(portProcesses) > 0 { - for _, pid := range portProcesses { - // Check if this process is actually a device-proxy process - if device_connect.IsDeviceProxyProcess(pid) { - if err := device_connect.KillProcess(pid, opts.Force); err == nil { - fmt.Printf("Killed process %d using port %d\n", pid, device_connect.DefaultPort) - } else { - fmt.Printf("Warning: failed to kill process %d using port %d: %v\n", pid, device_connect.DefaultPort, err) - } - } - } - } - - // Method 3: Find and kill processes by name - nameProcesses, err := device_connect.FindGboxDeviceProxyProcesses() - if err == nil && len(nameProcesses) > 0 { - for _, pid := range nameProcesses { - if err := device_connect.KillProcess(pid, opts.Force); err == nil { - fmt.Printf("Killed process %d by name\n", pid) - } else { - fmt.Printf("Warning: failed to kill process %d by name: %v\n", pid, err) - } - } - } - } - - // Check if any device-proxy processes are still running (only when --all is used) - if opts.All { - remainingPortProcesses, _ := device_connect.FindProcessesOnPort(device_connect.DefaultPort) - remainingNameProcesses, _ := device_connect.FindGboxDeviceProxyProcesses() - - // Filter out non-device-proxy processes from port processes - var deviceProxyPortProcesses []int - for _, pid := range remainingPortProcesses { - if device_connect.IsDeviceProxyProcess(pid) { - deviceProxyPortProcesses = append(deviceProxyPortProcesses, pid) - } - } - - if len(deviceProxyPortProcesses) == 0 && len(remainingNameProcesses) == 0 { - fmt.Println("Device proxy service stopped successfully.") - return nil - } else { - fmt.Println("Warning: Some device proxy processes may still be running:") - - // Show device-proxy processes found by port - if len(deviceProxyPortProcesses) > 0 { - fmt.Printf(" Device proxy processes using port %d:\n", device_connect.DefaultPort) - for _, pid := range deviceProxyPortProcesses { - if cmd, err := device_connect.GetProcessCommand(pid); err == nil { - fmt.Printf(" PID %d: %s\n", pid, cmd) - } else { - fmt.Printf(" PID %d: \n", pid) - } - } - } - - // Show processes found by name - if len(remainingNameProcesses) > 0 { - fmt.Println(" Device proxy processes found by name:") - for _, pid := range remainingNameProcesses { - if cmd, err := device_connect.GetProcessCommand(pid); err == nil { - fmt.Printf(" PID %d: %s\n", pid, cmd) - } else { - fmt.Printf(" PID %d: \n", pid) - } - } - } - - fmt.Println("Use 'gbox device-connect kill-server --all --force' to force kill all remaining processes.") - return nil - } - } else { - // When not using --all, just report success if PID file was handled - fmt.Println("Device proxy service stopped successfully.") - return nil - } -} diff --git a/packages/cli/cmd/device_connect_list.go b/packages/cli/cmd/device_connect_list.go index a2812f2c..c187b49b 100644 --- a/packages/cli/cmd/device_connect_list.go +++ b/packages/cli/cmd/device_connect_list.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect" + "github.com/babelcloud/gbox/packages/cli/internal/daemon" "github.com/spf13/cobra" ) @@ -31,15 +31,15 @@ func NewDeviceConnectListCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { return ExecuteDeviceConnectList(cmd, opts) }, - Example: ` # List all local Android devices and their registration status (default text format): + Example: ` # List all local Android devices and their registration status: gbox device-connect ls - # List all local Android devices and their registration status in JSON format: + # List devices in JSON format for scripting: gbox device-connect ls --format json`, } flags := cmd.Flags() - flags.StringVarP(&opts.OutputFormat, "format", "", "text", "Specify output format. Options are \"text\" (default) or \"json\".") + flags.StringVarP(&opts.OutputFormat, "format", "", "text", "Output format: text (default) or json") cmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"text", "json"}, cobra.ShellCompDirectiveNoFileComp @@ -54,31 +54,28 @@ func ExecuteDeviceConnectList(cmd *cobra.Command, opts *DeviceConnectListOptions return fmt.Errorf("ADB is not installed or not in your PATH. Please install ADB and try again.") } - if !checkFrpcInstalled() { - printFrpcInstallationHint() - return fmt.Errorf("frpc is not installed or not in your PATH. Please install frpc and try again.") + // Use daemon manager to call unified server API + var response struct { + Success bool `json:"success"` + Devices []map[string]interface{} `json:"devices"` } - - // Ensure device proxy service is running - if err := device_connect.EnsureDeviceProxyRunning(isServiceRunning); err != nil { - return fmt.Errorf("failed to start device proxy service: %v", err) - } - - client := getDeviceClient() - - devices, err := client.GetDevices() - if err != nil { + + if err := daemon.DefaultManager.CallAPI("GET", "/api/devices", nil, &response); err != nil { return fmt.Errorf("failed to get available devices: %v", err) } + + if !response.Success { + return fmt.Errorf("failed to get devices from server") + } if opts.OutputFormat == "json" { - return outputDevicesJSON(devices) + return outputDevicesJSONFromAPI(response.Devices) } - return outputDevicesText(devices) + return outputDevicesTextFromAPI(response.Devices) } -func outputDevicesJSON(devices []device_connect.DeviceInfo) error { +func outputDevicesJSONFromAPI(devices []map[string]interface{}) error { // Create a simplified JSON output for compatibility type SimpleDeviceInfo struct { DeviceID string `json:"device_id"` @@ -89,20 +86,25 @@ func outputDevicesJSON(devices []device_connect.DeviceInfo) error { var simpleDevices []SimpleDeviceInfo for _, device := range devices { + deviceID, _ := device["id"].(string) + name, _ := device["ro.product.model"].(string) + serialNo, _ := device["ro.serialno"].(string) + isRegistrable, _ := device["isRegistrable"].(bool) + status := statusNotRegistered - if device.IsRegistrable { + if isRegistrable { status = statusRegistered } deviceType := deviceTypeDevice // Check if it's an emulator based on serial number - if strings.Contains(strings.ToUpper(device.SerialNo), "EMULATOR") { + if strings.Contains(strings.ToUpper(serialNo), "EMULATOR") { deviceType = deviceTypeEmulator } simpleDevices = append(simpleDevices, SimpleDeviceInfo{ - DeviceID: device.Udid, - Name: device.ProductModel, + DeviceID: deviceID, + Name: name, Type: deviceType, ConnectionStatus: status, }) @@ -116,7 +118,7 @@ func outputDevicesJSON(devices []device_connect.DeviceInfo) error { return nil } -func outputDevicesText(devices []device_connect.DeviceInfo) error { +func outputDevicesTextFromAPI(devices []map[string]interface{}) error { if len(devices) == 0 { fmt.Println("No Android devices found.") return nil @@ -130,11 +132,14 @@ func outputDevicesText(devices []device_connect.DeviceInfo) error { // Find maximum widths for each column for _, device := range devices { - if len(device.Udid) > deviceIDWidth { - deviceIDWidth = len(device.Udid) + deviceID, _ := device["id"].(string) + name, _ := device["ro.product.model"].(string) + + if len(deviceID) > deviceIDWidth { + deviceIDWidth = len(deviceID) } - if len(device.ProductModel) > nameWidth { - nameWidth = len(device.ProductModel) + if len(name) > nameWidth { + nameWidth = len(name) } if len(deviceTypeEmulator) > typeWidth { typeWidth = len(deviceTypeEmulator) @@ -159,20 +164,25 @@ func outputDevicesText(devices []device_connect.DeviceInfo) error { // Print data rows for _, device := range devices { + deviceID, _ := device["id"].(string) + name, _ := device["ro.product.model"].(string) + serialNo, _ := device["ro.serialno"].(string) + isRegistrable, _ := device["isRegistrable"].(bool) + status := statusNotRegistered - if device.IsRegistrable { + if isRegistrable { status = statusRegistered } deviceType := deviceTypeDevice // Check if it's an emulator based on serial number - if strings.Contains(strings.ToUpper(device.SerialNo), "EMULATOR") { + if strings.Contains(strings.ToUpper(serialNo), "EMULATOR") { deviceType = deviceTypeEmulator } fmt.Printf("%-*s %-*s %-*s %-*s\n", - deviceIDWidth, device.Id, - nameWidth, device.ProductModel, + deviceIDWidth, deviceID, + nameWidth, name, typeWidth, deviceType, statusWidth, status) } diff --git a/packages/cli/cmd/device_connect_register.go b/packages/cli/cmd/device_connect_register.go new file mode 100644 index 00000000..59149762 --- /dev/null +++ b/packages/cli/cmd/device_connect_register.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "fmt" + + "github.com/babelcloud/gbox/packages/cli/internal/daemon" + "github.com/babelcloud/gbox/packages/cli/internal/profile" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +type DeviceConnectRegisterOptions struct { + DeviceID string +} + +func NewDeviceConnectRegisterCommand() *cobra.Command { + opts := &DeviceConnectRegisterOptions{} + + cmd := &cobra.Command{ + Use: "register [device_id] [flags]", + Aliases: []string{"reg"}, + Short: "Register an Android device for remote access", + Long: "Register an Android device for remote access. If no device ID is provided, an interactive selection will be shown.", + Example: ` # Interactively select a device to register + gbox device-connect register + + # Register specific device + gbox device-connect register abc123xyz456-usb`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return ExecuteDeviceConnectRegister(cmd, opts, args) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&opts.DeviceID, "device", "d", "", "Specify the Android device ID to register") + + return cmd +} + +func ExecuteDeviceConnectRegister(cmd *cobra.Command, opts *DeviceConnectRegisterOptions, args []string) error { + if !checkAdbInstalled() { + printAdbInstallationHint() + return fmt.Errorf("ADB is not installed or not in your PATH. Please install ADB and try again.") + } + + var deviceID string + if len(args) > 0 { + deviceID = args[0] + } else if opts.DeviceID != "" { + deviceID = opts.DeviceID + } + + if deviceID == "" { + return runInteractiveDeviceRegistration() + } + + return registerDevice(deviceID) +} + +func runInteractiveDeviceRegistration() error { + // Use daemon manager to call API + var response struct { + Success bool `json:"success"` + Devices []map[string]interface{} `json:"devices"` + } + + if err := daemon.DefaultManager.CallAPI("GET", "/api/devices", nil, &response); err != nil { + return fmt.Errorf("failed to get available devices: %v", err) + } + + if !response.Success { + return fmt.Errorf("failed to get devices from server") + } + + devices := response.Devices + if len(devices) == 0 { + fmt.Println("No Android devices found.") + fmt.Println() + printDeveloperModeHint() + return nil + } + + fmt.Println() + fmt.Println("Select a device to register for remote access:") + fmt.Println() + printDeveloperModeHint() + fmt.Println() + + for i, device := range devices { + status := "Not Registered" + statusColor := color.New(color.Faint) + + // Extract device info from map + serialNo := device["ro.serialno"].(string) + connectionType := device["connectionType"].(string) + isRegistered, _ := device["isRegistrable"].(bool) + + if isRegistered { + status = "Registered" + statusColor = color.New(color.FgGreen) + } + + model := "Unknown" + if m, ok := device["ro.product.model"].(string); ok { + model = m + } + + manufacturer := "" + if mfr, ok := device["ro.product.manufacturer"].(string); ok { + manufacturer = mfr + } + + fmt.Printf("%d. %s (%s, %s) - %s [%s]\n", + i+1, + color.New(color.FgCyan).Sprint(serialNo+"-"+connectionType), + model, + connectionType, + manufacturer, + statusColor.Sprint(status)) + } + fmt.Println() + fmt.Print("Enter a number: ") + var choice int + fmt.Scanf("%d", &choice) + if choice < 1 || choice > len(devices) { + return fmt.Errorf("invalid selection: %d", choice) + } + + selectedDevice := devices[choice-1] + deviceID := selectedDevice["id"].(string) + return registerDevice(deviceID) +} + +func registerDevice(deviceID string) error { + // Register device via daemon API + req := map[string]string{"deviceId": deviceID} + var resp map[string]interface{} + + if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/register", req, &resp); err != nil { + return fmt.Errorf("failed to register device: %v", err) + } + + if success, ok := resp["success"].(bool); !ok || !success { + return fmt.Errorf("failed to register device: %v", resp["error"]) + } + + fmt.Printf("Establishing remote connection for device %s...\n", deviceID) + fmt.Printf("Connection established successfully!\n") + + // Display local Web UI URL + fmt.Printf("\n📱 View and control your device at: %s\n", color.CyanString("http://localhost:29888")) + fmt.Printf(" This is the local live-view interface for device control\n") + + // Get and display devices URL for the current profile + pm := profile.NewProfileManager() + if err := pm.Load(); err == nil { + if devicesURL, err := pm.GetDevicesURL(); err == nil { + fmt.Printf("\n☁️ Remote access available at: %s\n", color.CyanString(devicesURL)) + } + } + + fmt.Printf("\n💡 Device registered successfully. Use 'gbox device-connect unregister %s' to disconnect when needed.\n", deviceID) + + return nil +} \ No newline at end of file diff --git a/packages/cli/cmd/device_connect_server.go b/packages/cli/cmd/device_connect_server.go deleted file mode 100644 index c76b11d1..00000000 --- a/packages/cli/cmd/device_connect_server.go +++ /dev/null @@ -1,48 +0,0 @@ -package cmd - -import ( - "fmt" - "log" - "os" - "os/signal" - "syscall" - - "github.com/babelcloud/gbox/packages/cli/internal/device_connect" - "github.com/spf13/cobra" -) - -// NewDeviceConnectServerCommand creates the start-server subcommand -func NewDeviceConnectServerCommand() *cobra.Command { - var port int - - cmd := &cobra.Command{ - Use: "start-server", - Short: "Start the native device proxy server", - Hidden: true, // Hidden command for internal use - RunE: func(cmd *cobra.Command, args []string) error { - // This command is called by the daemon process - log.Printf("Starting native device proxy server on port %d", port) - - server := device_connect.NewServer(port) - if err := server.Start(); err != nil { - return fmt.Errorf("failed to start server: %v", err) - } - - // Wait for interrupt signal - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - <-sigChan - - log.Println("Shutting down server...") - if err := server.Stop(); err != nil { - log.Printf("Error stopping server: %v", err) - } - - return nil - }, - } - - cmd.Flags().IntVar(&port, "port", device_connect.DefaultPort, "Server port") - - return cmd -} \ No newline at end of file diff --git a/packages/cli/cmd/device_connect_unregister.go b/packages/cli/cmd/device_connect_unregister.go index 2ced1fd9..c73aa2fa 100644 --- a/packages/cli/cmd/device_connect_unregister.go +++ b/packages/cli/cmd/device_connect_unregister.go @@ -3,7 +3,7 @@ package cmd import ( "fmt" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect" + "github.com/babelcloud/gbox/packages/cli/internal/daemon" "github.com/spf13/cobra" ) @@ -42,11 +42,6 @@ func ExecuteDeviceConnectUnregister(cmd *cobra.Command, opts *DeviceConnectUnreg return fmt.Errorf("ADB is not installed or not in your PATH. Please install ADB and try again.") } - if !checkFrpcInstalled() { - printFrpcInstallationHint() - return fmt.Errorf("frpc is not installed or not in your PATH. Please install frpc and try again.") - } - if opts.All { return unregisterAllDevices() } @@ -60,25 +55,37 @@ func ExecuteDeviceConnectUnregister(cmd *cobra.Command, opts *DeviceConnectUnreg } func unregisterAllDevices() error { - client := getDeviceClient() - - devices, err := client.GetDevices() - if err != nil { + // Get devices from daemon manager + var response struct { + Success bool `json:"success"` + Devices []map[string]interface{} `json:"devices"` + } + + if err := daemon.DefaultManager.CallAPI("GET", "/api/devices", nil, &response); err != nil { return fmt.Errorf("failed to get available devices: %v", err) } + + if !response.Success { + return fmt.Errorf("failed to get devices from server") + } unregisteredCount := 0 - for _, device := range devices { - if device.IsRegistrable { - fmt.Printf("Unregistering %s (%s, %s)...\n", - device.Udid, device.ProductModel, device.ConnectionType) - - if err := client.UnregisterDevice(device.Udid); err != nil { - fmt.Printf("Failed to unregister %s: %v\n", device.Udid, err) + for _, device := range response.Devices { + deviceID, _ := device["id"].(string) + name, _ := device["ro.product.model"].(string) + connectionType, _ := device["connectionType"].(string) + isRegistrable, _ := device["isRegistrable"].(bool) + + if isRegistrable { + fmt.Printf("Unregistering %s (%s, %s)...\n", deviceID, name, connectionType) + + req := map[string]string{"deviceId": deviceID} + if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/unregister", req, nil); err != nil { + fmt.Printf("Failed to unregister %s: %v\n", deviceID, err) continue } - fmt.Printf("Device %s unregistered successfully.\n", device.Udid) + fmt.Printf("Device %s unregistered successfully.\n", deviceID) unregisteredCount++ } } @@ -93,17 +100,47 @@ func unregisterAllDevices() error { } func unregisterDevice(deviceID string) error { - client := getDeviceClient() + // Get device info first to show details + var response struct { + Success bool `json:"success"` + Devices []map[string]interface{} `json:"devices"` + } + + if err := daemon.DefaultManager.CallAPI("GET", "/api/devices", nil, &response); err != nil { + return fmt.Errorf("failed to get available devices: %v", err) + } + + if !response.Success { + return fmt.Errorf("failed to get devices from server") + } - device, err := client.GetDeviceInfo(deviceID) - if err != nil { + // Find the device to get its details + var targetDevice map[string]interface{} + for _, device := range response.Devices { + if deviceID == device["id"].(string) { + targetDevice = device + break + } + } + + if targetDevice == nil { return fmt.Errorf("device not found: %s", deviceID) } - fmt.Printf("Unregistering %s (%s, %s)...\n", - deviceID, device.ProductModel, device.ConnectionType) + model := "Unknown" + if m, ok := targetDevice["ro.product.model"].(string); ok { + model = m + } + + connectionType := "Unknown" + if ct, ok := targetDevice["connectionType"].(string); ok { + connectionType = ct + } + + fmt.Printf("Unregistering %s (%s, %s)...\n", deviceID, model, connectionType) - if err := client.UnregisterDevice(deviceID); err != nil { + req := map[string]string{"deviceId": deviceID} + if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/unregister", req, nil); err != nil { return fmt.Errorf("failed to unregister device: %v", err) } @@ -113,17 +150,24 @@ func unregisterDevice(deviceID string) error { } func runInteractiveUnregisterSelection() error { - client := getDeviceClient() - - devices, err := client.GetDevices() - if err != nil { + // Get devices from daemon manager + var response struct { + Success bool `json:"success"` + Devices []map[string]interface{} `json:"devices"` + } + + if err := daemon.DefaultManager.CallAPI("GET", "/api/devices", nil, &response); err != nil { return fmt.Errorf("failed to get available devices: %v", err) } + + if !response.Success { + return fmt.Errorf("failed to get devices from server") + } // Filter only registered devices - var registeredDevices []device_connect.DeviceInfo - for _, device := range devices { - if device.IsRegistrable { + var registeredDevices []map[string]interface{} + for _, device := range response.Devices { + if isRegistrable, ok := device["isRegistrable"].(bool); ok && isRegistrable { registeredDevices = append(registeredDevices, device) } } @@ -137,12 +181,26 @@ func runInteractiveUnregisterSelection() error { fmt.Println() for i, device := range registeredDevices { + deviceID, _ := device["id"].(string) + model := "Unknown" + if m, ok := device["ro.product.model"].(string); ok { + model = m + } + connectionType := "Unknown" + if ct, ok := device["connectionType"].(string); ok { + connectionType = ct + } + manufacturer := "" + if mfr, ok := device["ro.product.manufacturer"].(string); ok { + manufacturer = mfr + } + fmt.Printf("%d. %s (%s, %s) - %s\n", i+1, - device.Udid, - device.ProductModel, - device.ConnectionType, - device.ProductManufacturer) + deviceID, + model, + connectionType, + manufacturer) } fmt.Println() @@ -156,5 +214,6 @@ func runInteractiveUnregisterSelection() error { } selectedDevice := registeredDevices[choice-1] - return unregisterDevice(selectedDevice.Udid) + deviceID := selectedDevice["id"].(string) + return unregisterDevice(deviceID) } diff --git a/packages/cli/cmd/render.go b/packages/cli/cmd/render.go index 6ab4de8f..1b31753b 100644 --- a/packages/cli/cmd/render.go +++ b/packages/cli/cmd/render.go @@ -12,8 +12,8 @@ type TableColumn struct { Width int // calculated width } -// renderTable renders a table with dynamic column width calculation -func renderTable(columns []TableColumn, data []map[string]interface{}) { +// RenderTable renders a table with dynamic column width calculation +func RenderTable(columns []TableColumn, data []map[string]interface{}) { if len(data) == 0 { fmt.Println("No data to display") return diff --git a/packages/cli/cmd/server.go b/packages/cli/cmd/server.go index a78bb00b..3ce25d1e 100644 --- a/packages/cli/cmd/server.go +++ b/packages/cli/cmd/server.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "net" "net/http" "os" "os/signal" @@ -36,6 +37,7 @@ func newServerStartCmd() *cobra.Command { var ( port int foreground bool + replyFd int ) cmd := &cobra.Command{ @@ -47,8 +49,8 @@ func newServerStartCmd() *cobra.Command { // Run in foreground mode return runServerInForeground(port) } - // Run in background mode (default) - return startServerInBackground(port) + // Default: run in daemon mode with IPC communication + return runServerInDaemon(port, replyFd) }, Example: ` # Start server in background gbox server start @@ -64,6 +66,7 @@ func newServerStartCmd() *cobra.Command { flags := cmd.Flags() flags.IntVarP(&port, "port", "p", 29888, "Server port") flags.BoolVarP(&foreground, "foreground", "f", false, "Run server in foreground (show logs)") + flags.IntVar(&replyFd, "reply-fd", 0, "File descriptor for IPC communication (internal use)") return cmd } @@ -78,20 +81,20 @@ func newServerStopCmd() *cobra.Command { Long: `Stop the gbox server if it's running.`, RunE: func(cmd *cobra.Command, args []string) error { dm := daemon.NewManager() - + if force { // Force stop all processes dm.CleanupOldServers() fmt.Println("Force stopped all server processes") return nil } - + // Normal stop if !dm.IsServerRunning() { fmt.Println("Server is not running") return nil } - + fmt.Println("Stopping gbox server...") if err := dm.StopServer(); err != nil { // Try force cleanup if normal stop fails @@ -99,7 +102,7 @@ func newServerStopCmd() *cobra.Command { fmt.Println("Server stopped (forced)") return nil } - + fmt.Println("Server stopped successfully") return nil }, @@ -125,12 +128,12 @@ func newServerStatusCmd() *cobra.Command { Long: `Check if the gbox server is running and display its status.`, RunE: func(cmd *cobra.Command, args []string) error { dm := daemon.NewManager() - + if dm.IsServerRunning() { fmt.Println("✅ Server is running") fmt.Printf(" Web UI: http://localhost:29888\n") fmt.Printf(" API endpoint: http://localhost:29888/api/status\n") - + // Try to get more info from API client := &http.Client{Timeout: 2 * time.Second} if resp, err := client.Get("http://localhost:29888/api/status"); err == nil { @@ -151,7 +154,7 @@ func newServerStatusCmd() *cobra.Command { fmt.Println("❌ Server is not running") fmt.Println(" Use 'gbox server start' to start the server") } - + return nil }, } @@ -172,7 +175,7 @@ func newServerRestartCmd() *cobra.Command { Long: `Stop and then start the gbox server.`, RunE: func(cmd *cobra.Command, args []string) error { dm := daemon.NewManager() - + // Stop server if it's running if dm.IsServerRunning() { fmt.Println("Stopping gbox server...") @@ -183,24 +186,24 @@ func newServerRestartCmd() *cobra.Command { } else { fmt.Println("Server stopped successfully") } - + // Wait a moment for cleanup time.Sleep(500 * time.Millisecond) } - + // Start server if foreground { // Run in foreground mode fmt.Println("Restarting server in foreground mode...") return runServerInForeground(port) } - + // Start in background mode fmt.Printf("Starting gbox server on port %d...\n", port) if err := dm.StartServer(); err != nil { return fmt.Errorf("failed to start server: %v", err) } - + fmt.Println("Server restarted successfully") fmt.Printf("Web UI available at: http://localhost:%d\n", port) return nil @@ -225,21 +228,63 @@ func newServerRestartCmd() *cobra.Command { // Helper functions +// runServerInDaemon runs the server in daemon mode with IPC communication +func runServerInDaemon(port int, replyFd int) error { + // Start the server + server := server.NewGBoxServer(port) + + // Start server in a goroutine + errChan := make(chan error, 1) + go func() { + errChan <- server.Start() + }() + + // Wait a bit for server to start + time.Sleep(2 * time.Second) + + // Check if server started successfully by trying to connect + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 1*time.Second) + if err != nil { + // Server failed to start + if replyFd > 0 { + replyFile := os.NewFile(uintptr(replyFd), "reply") + if replyFile != nil { + replyFile.WriteString(fmt.Sprintf("ERROR: server failed to start: %v", err)) + replyFile.Close() + } + } + return fmt.Errorf("server failed to start: %v", err) + } + conn.Close() + + // Server started successfully + if replyFd > 0 { + replyFile := os.NewFile(uintptr(replyFd), "reply") + if replyFile != nil { + replyFile.WriteString("OK") + replyFile.Close() + } + } + + // Keep the server running + select {} +} + func startServerInBackground(port int) error { dm := daemon.NewManager() - + // Check if already running if dm.IsServerRunning() { fmt.Println("Server is already running") fmt.Printf("Web UI available at: http://localhost:%d\n", port) return nil } - + fmt.Printf("Starting gbox server on port %d...\n", port) if err := dm.StartServer(); err != nil { return fmt.Errorf("failed to start server: %v", err) } - + fmt.Println("Server started successfully") fmt.Printf("Web UI available at: http://localhost:%d\n", port) return nil @@ -254,19 +299,23 @@ func runServerInForeground(port int) error { return fmt.Errorf("server already running") } - log.Printf("Starting gbox server on port %d (foreground mode)", port) + // Starting server in foreground mode srv := server.NewGBoxServer(port) if err := srv.Start(); err != nil { return fmt.Errorf("failed to start server: %v", err) } - fmt.Printf("Server running on http://localhost:%d\n", port) - fmt.Println("Services available:") - fmt.Printf(" - Device Connect (WebRTC): http://localhost:%d/\n", port) - fmt.Printf(" - ADB Expose API: http://localhost:%d/api/adb-expose/status\n", port) - fmt.Printf(" - Server Status: http://localhost:%d/api/status\n", port) - fmt.Println("\nPress Ctrl+C to stop...") + // ANSI color codes + const ( + ColorReset = "\033[0m" + ColorGreen = "\033[32m" + ColorBlue = "\033[34m" + ColorCyan = "\033[36m" + ) + + fmt.Printf("%s🚀 GBOX Local Server%s %s➜ %shttp://localhost:%d%s\n", ColorGreen, ColorReset, ColorCyan, ColorBlue, port, ColorReset) + fmt.Printf("%sPress Ctrl+C to stop...%s\n", ColorCyan, ColorReset) // Wait for interrupt signal sigChan := make(chan os.Signal, 1) @@ -279,4 +328,4 @@ func runServerInForeground(port int) error { } return nil -} \ No newline at end of file +} diff --git a/packages/cli/internal/adb_expose/client.go b/packages/cli/internal/adb_expose/client.go index 3599b83b..c34da507 100644 --- a/packages/cli/internal/adb_expose/client.go +++ b/packages/cli/internal/adb_expose/client.go @@ -1,291 +1,114 @@ package adb_expose import ( - "encoding/binary" + "bytes" + "encoding/json" "fmt" - "log" - "net" - "sync" + "net/http" "time" - - "github.com/gorilla/websocket" -) - -const ( - TypeOpen = iota + 1 - TypeData - TypeClose - TypeError - TypeAck ) -type Config struct { - APIKey string - BoxID string - GboxURL string - LocalAddr string - TargetPorts []int -} - -type PortForwardRequest struct { - Ports []int `json:"ports"` -} - -type PortForwardResponse struct { - URL string `json:"url"` -} - -type Stream struct { - id uint32 - localConn net.Conn - closeCh chan struct{} - readyCh chan struct{} - mu sync.Mutex - closed bool - ready bool -} - -type MultiplexClient struct { - ws *websocket.Conn - streams map[uint32]*Stream - mu sync.RWMutex - nextID uint32 - muID sync.Mutex - closeCh chan struct{} - writeMu sync.Mutex -} - -func NewMultiplexClient(ws *websocket.Conn) *MultiplexClient { - return &MultiplexClient{ - ws: ws, - streams: make(map[uint32]*Stream), - closeCh: make(chan struct{}), +// ForwardInfo represents information about a port forward +type ForwardInfo struct { + BoxID string `json:"box_id"` + LocalPorts []int `json:"local_ports"` + RemotePorts []int `json:"remote_ports"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + Error string `json:"error,omitempty"` +} + +// Client represents an ADB expose client +type Client struct { + serverURL string + client *http.Client +} + +// NewClient creates a new ADB expose client +func NewClient(serverURL string) *Client { + return &Client{ + serverURL: serverURL, + client: &http.Client{ + Timeout: 10 * time.Second, + }, } } -func (m *MultiplexClient) Close() { - select { - case <-m.closeCh: - default: - close(m.closeCh) +// Start starts ADB port exposure for a box +func (c *Client) Start(boxID string, localPorts, remotePorts []int) error { + reqBody := map[string]interface{}{ + "box_id": boxID, + "local_ports": localPorts, + "remote_ports": remotePorts, } - m.mu.Lock() - defer m.mu.Unlock() - - for _, stream := range m.streams { - stream.Close() - } - m.streams = nil + return c.makeRequest("POST", "/api/adb-expose/start", reqBody) } -func (m *MultiplexClient) Run() error { - for { - select { - case <-m.closeCh: - return nil - default: - messageType, data, err := m.ws.ReadMessage() - if err != nil { - return fmt.Errorf("websocket read error: %v", err) - } - - if messageType != websocket.BinaryMessage { - continue - } - - msgType, streamID, payload, err := parseMessage(data) - if err != nil { - log.Printf("parse message error: %v", err) - continue - } - - switch msgType { - case TypeData: - m.HandleData(streamID, payload) - case TypeClose: - m.HandleClose(streamID) - case TypeError: - m.HandleError(streamID, payload) - case TypeAck: - m.HandleAck(streamID) - default: - log.Printf("unknown message type: %d", msgType) - } - } +// Stop stops ADB port exposure for a box +func (c *Client) Stop(boxID string) error { + reqBody := map[string]interface{}{ + "box_id": boxID, } -} -func (m *MultiplexClient) HandleData(streamID uint32, payload []byte) { - m.mu.RLock() - stream, exists := m.streams[streamID] - m.mu.RUnlock() - - if !exists { - log.Printf("stream %d not found", streamID) - return - } + return c.makeRequest("POST", "/api/adb-expose/stop", reqBody) +} - _, err := stream.localConn.Write(payload) +// List returns all active ADB port exposures +func (c *Client) List() ([]ForwardInfo, error) { + resp, err := c.client.Get(c.serverURL + "/api/adb-expose/list") if err != nil { - log.Printf("localConn.Write error: %v", err) - stream.Close() - m.RemoveStream(streamID) + return nil, fmt.Errorf("failed to send request: %v", err) } -} - -func (m *MultiplexClient) HandleClose(streamID uint32) { - m.mu.RLock() - stream, exists := m.streams[streamID] - m.mu.RUnlock() + defer resp.Body.Close() - if exists { - stream.Close() - m.RemoveStream(streamID) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned status %d", resp.StatusCode) } -} -func (m *MultiplexClient) HandleError(streamID uint32, payload []byte) { - log.Printf("server error for stream %d: %s", streamID, string(payload)) - m.HandleClose(streamID) -} - -func (m *MultiplexClient) HandleAck(streamID uint32) { - m.mu.RLock() - stream, exists := m.streams[streamID] - m.mu.RUnlock() - - if !exists { - log.Printf("received ack for unknown stream %d", streamID) - return + var result struct { + Forwards []ForwardInfo `json:"forwards"` } - - stream.mu.Lock() - if !stream.ready { - stream.ready = true - close(stream.readyCh) + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %v", err) } - stream.mu.Unlock() -} - -func (m *MultiplexClient) NewStreamID() uint32 { - m.muID.Lock() - defer m.muID.Unlock() - // client use even id, server use odd id - // if future need client to access server, server use odd id - m.nextID += 2 - return m.nextID -} - -func (m *MultiplexClient) SendMessage(msgType byte, streamID uint32, payload []byte) error { - m.writeMu.Lock() - defer m.writeMu.Unlock() - - message := make([]byte, 5+len(payload)) - message[0] = msgType - binary.BigEndian.PutUint32(message[1:5], streamID) - copy(message[5:], payload) - return m.ws.WriteMessage(websocket.BinaryMessage, message) + return result.Forwards, nil } -func (m *MultiplexClient) HandleStream(stream *Stream) { - defer func() { - stream.Close() - m.RemoveStream(stream.id) - }() - - select { - case <-stream.readyCh: - case <-stream.closeCh: - return - case <-time.After(10 * time.Second): - log.Printf("timeout waiting for server ack for stream %d", stream.id) - m.SendMessage(TypeClose, stream.id, nil) - return +// makeRequest makes an HTTP request to the server +func (c *Client) makeRequest(method, endpoint string, reqBody interface{}) error { + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %v", err) } - buf := make([]byte, 4096) - for { - select { - case <-stream.closeCh: - return - default: - n, err := stream.localConn.Read(buf) - if err != nil { - m.SendMessage(TypeClose, stream.id, nil) - return - } - - err = m.SendMessage(TypeData, stream.id, buf[:n]) - if err != nil { - log.Printf("sendMessage error: %v", err) - return - } - } + resp, err := c.client.Post(c.serverURL+endpoint, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to send request: %v", err) } -} - -func (m *MultiplexClient) RemoveStream(streamID uint32) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.streams, streamID) -} + defer resp.Body.Close() -func (m *MultiplexClient) AddStream(streamID uint32, localConn net.Conn) *Stream { - stream := &Stream{ - id: streamID, - localConn: localConn, - closeCh: make(chan struct{}), - readyCh: make(chan struct{}), - ready: false, + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse response: %v", err) } - m.mu.Lock() - if m.streams == nil { - m.mu.Unlock() - log.Printf("streams map is nil, client may be closed") - stream.Close() - return stream + if resp.StatusCode == http.StatusConflict { + fmt.Printf("ADB port is already exposed for box %s\n", reqBody.(map[string]interface{})["box_id"]) + return nil } - m.streams[streamID] = stream - m.mu.Unlock() - - go m.HandleStream(stream) - - return stream -} -func (s *Stream) Close() { - s.mu.Lock() - defer s.mu.Unlock() - if !s.closed { - s.closed = true - close(s.closeCh) - if !s.ready { - close(s.readyCh) - } - s.localConn.Close() + if resp.StatusCode != http.StatusOK { + errorMsg, _ := result["error"].(string) + return fmt.Errorf("server error: %s", errorMsg) } -} - -func HandleLocalConnWithClient(localConn net.Conn, client *MultiplexClient, remotePort int) { - defer func() { - localConn.Close() - }() - streamID := client.NewStreamID() - stream := client.AddStream(streamID, localConn) - - // start multiplexing - // the payload is : - // remote server limit the ip, so we use any valid ip as payload - // And the must be in the port-forward-url response, remote server will check it - err := client.SendMessage(TypeOpen, streamID, []byte(fmt.Sprintf("127.0.0.1:%d", remotePort))) - if err != nil { - log.Printf("send open message error: %v", err) - return + success, _ := result["success"].(bool) + if !success { + errorMsg, _ := result["error"].(string) + return fmt.Errorf("operation failed: %s", errorMsg) } - <-stream.closeCh -} + return nil +} \ No newline at end of file diff --git a/packages/cli/internal/adb_expose/commands.go b/packages/cli/internal/adb_expose/commands.go new file mode 100644 index 00000000..1810f50a --- /dev/null +++ b/packages/cli/internal/adb_expose/commands.go @@ -0,0 +1,603 @@ +package adb_expose + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + sdk "github.com/babelcloud/gbox-sdk-go" + gboxsdk "github.com/babelcloud/gbox/packages/cli/internal/client" +) + +// StartCommand starts port forwarding using the main GBOX server API +func StartCommand(boxID string, localPorts, remotePorts []int, foreground bool) error { + // First check if the box exists + if err := checkBoxExists(boxID); err != nil { + return err + } + + // Ensure main GBOX server is running + if err := ensureServerRunning(); err != nil { + return fmt.Errorf("failed to start server: %v", err) + } + + // Create request payload + reqBody := map[string]interface{}{ + "box_id": boxID, + "local_ports": localPorts, + "remote_ports": remotePorts, + } + + // Convert to JSON + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %v", err) + } + + // Send HTTP request to main server + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Post("http://127.0.0.1:29888/api/adb-expose/start", + "application/json", + bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to send request to server: %v", err) + } + defer resp.Body.Close() + + // Parse response + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + + // Check if request was successful + if resp.StatusCode == http.StatusConflict { + // Handle 409 Conflict - already running + fmt.Printf("ADB port is already exposed for box %s\n", boxID) + return nil + } + + if resp.StatusCode != http.StatusOK { + errorMsg, _ := result["error"].(string) + // Check for specific error types and provide user-friendly messages + if strings.Contains(errorMsg, "box is not running") { + return fmt.Errorf("box %s is not running or does not exist", boxID) + } + return fmt.Errorf("server error: %s", errorMsg) + } + + success, _ := result["success"].(bool) + if !success { + errorMsg, _ := result["error"].(string) + // Check for specific error types and provide user-friendly messages + if strings.Contains(errorMsg, "box is not running") { + return fmt.Errorf("box %s is not running or does not exist", boxID) + } + return fmt.Errorf("failed to start ADB port expose: %s", errorMsg) + } + + // Print success message + fmt.Printf("✅ ADB port exposed for box %s on port %v\n", boxID, localPorts[0]) + + if !foreground { + fmt.Printf("\n💡 Use 'gbox adb-expose list' to view all exposed ports\n") + fmt.Printf(" Use 'gbox adb-expose stop %s' to stop\n", boxID) + } + + return nil +} + +// StopCommand stops port forwarding using the main GBOX server API +func StopCommand(boxID string) error { + // First check if the box exists + if err := checkBoxExists(boxID); err != nil { + return err + } + + // Ensure main GBOX server is running + if err := ensureServerRunning(); err != nil { + return fmt.Errorf("failed to start server: %v", err) + } + + // Create request payload + reqBody := map[string]interface{}{ + "box_id": boxID, + } + + // Convert to JSON + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %v", err) + } + + // Send HTTP request to main server + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Post("http://127.0.0.1:29888/api/adb-expose/stop", + "application/json", + bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to send request to server: %v", err) + } + defer resp.Body.Close() + + // Parse response + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + + // Check if request was successful + if resp.StatusCode == http.StatusNotFound { + // Box exists but ADB port expose is not active + return fmt.Errorf("ADB port expose is not active for box %s", boxID) + } + + if resp.StatusCode != http.StatusOK { + errorMsg, _ := result["error"].(string) + return fmt.Errorf("server error: %s", errorMsg) + } + + success, _ := result["success"].(bool) + if !success { + errorMsg, _ := result["error"].(string) + return fmt.Errorf("failed to stop ADB port expose: %s", errorMsg) + } + + // Print success message + fmt.Printf("✅ ADB port expose stopped for box %s\n", boxID) + + return nil +} + +// ListCommand lists all running port forwards using the new client-server architecture +func ListCommand(outputFormat string) error { + // Ensure server is running before making requests + if err := ensureServerRunning(); err != nil { + return fmt.Errorf("failed to start server: %v", err) + } + + // Check if main server is running by trying to connect to it + client := &http.Client{ + Timeout: 2 * time.Second, + } + + // Try to get ADB Expose list from the main server + resp, err := client.Get("http://127.0.0.1:29888/api/adb-expose/list") + if err != nil { + fmt.Println("ADB Expose server is not running") + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Println("ADB Expose server is not responding properly") + return nil + } + + // Parse response + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + + // Display results + forwards, ok := result["forwards"].([]interface{}) + if !ok || len(forwards) == 0 { + fmt.Println("No ADB ports are currently exposed") + return nil + } + + // Convert to table data format + var tableData []map[string]interface{} + for _, forward := range forwards { + f, ok := forward.(map[string]interface{}) + if !ok { + continue + } + + boxID, _ := f["box_id"].(string) + localPorts, _ := f["local_ports"].([]interface{}) + startedAt, _ := f["started_at"].(string) + + localPortStr := formatPortsFromInterface(localPorts) + + // Don't truncate box ID - show full ID + + tableData = append(tableData, map[string]interface{}{ + "box_id": boxID, + "port": localPortStr, + "started_at": startedAt, + }) + } + + // Output based on format + if outputFormat == "json" { + // Output JSON format + jsonData, err := json.MarshalIndent(map[string]interface{}{ + "forwards": tableData, + }, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %v", err) + } + fmt.Println(string(jsonData)) + } else { + // Render table + renderTable(tableData) + } + + return nil +} + +// formatPortsFromInterface formats a slice of ports from interface{} as a string +func formatPortsFromInterface(ports []interface{}) string { + if len(ports) == 0 { + return "none" + } + + portStrs := make([]string, len(ports)) + for i, port := range ports { + if portFloat, ok := port.(float64); ok { + portStrs[i] = strconv.Itoa(int(portFloat)) + } else if portStr, ok := port.(string); ok { + portStrs[i] = portStr + } else { + portStrs[i] = "unknown" + } + } + return strings.Join(portStrs, ",") +} + +// renderTable renders the ADB Expose list table +func renderTable(data []map[string]interface{}) { + if len(data) == 0 { + fmt.Println("No ADB ports are currently exposed") + return + } + + // Print table header + fmt.Printf("%-40s %-12s %-25s\n", "Box ID", "Port", "Started At") + fmt.Println(strings.Repeat("-", 77)) + + // Print data rows + for _, row := range data { + boxID, _ := row["box_id"].(string) + port, _ := row["port"].(string) + startedAt, _ := row["started_at"].(string) + + fmt.Printf("%-40s %-12s %-25s\n", boxID, port, startedAt) + } +} + +// ensureServerRunning ensures the GBOX server is running, starting it if necessary +func ensureServerRunning() error { + // Check if server is already running + if isServerRunning() { + // Check if server version matches current build + if !isServerVersionCompatible() { + fmt.Println("🔄 Server version mismatch, restarting server...") + // Kill existing server and start new one + if err := killExistingServer(); err != nil { + fmt.Printf("⚠️ Warning: failed to kill existing server: %v\n", err) + } + return startServerInBackground() + } + return nil + } + + // Start server in background + return startServerInBackground() +} + +// isServerRunning checks if the server is already running +func isServerRunning() bool { + conn, err := http.Get("http://127.0.0.1:29888/health") + if err != nil { + return false + } + defer conn.Body.Close() + return conn.StatusCode == http.StatusOK +} + +// isServerVersionCompatible checks if the running server version matches current build +func isServerVersionCompatible() bool { + // Get server build ID + serverBuildID, err := getServerBuildID() + if err != nil { + // If we can't get server build ID, assume incompatible + return false + } + + // Get current build ID + currentBuildID := getCurrentBuildID() + + // For development, we'll use a more lenient approach: + // If both build IDs contain "unknown" (development mode), do exact comparison + // This ensures that recompiled binaries trigger server restart + if strings.Contains(serverBuildID, "unknown") && strings.Contains(currentBuildID, "unknown") { + return currentBuildID == serverBuildID + } + + // For production builds, do exact comparison + return currentBuildID == serverBuildID +} + +// getCurrentBuildID returns the current build ID +func getCurrentBuildID() string { + // For development, use a simple approach that changes when binary is recompiled + // In production, this would be set by build scripts + execPath, err := os.Executable() + if err != nil { + return "unknown" + } + + info, err := os.Stat(execPath) + if err != nil { + return "unknown" + } + + // Use modification time + file size to detect binary changes + // This will change when the binary is recompiled + buildTime := info.ModTime().Format("2006-01-02T15:04:05") // No timezone, more stable + gitCommit := "unknown" + fileSize := info.Size() + + return fmt.Sprintf("%s-%s-%d", buildTime, gitCommit, fileSize) +} + +// getServerBuildID gets the build ID from the running server +func getServerBuildID() (string, error) { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:29888/api/server/info") + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("server returned status %d", resp.StatusCode) + } + + var info map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return "", err + } + + buildID, ok := info["build_id"].(string) + if !ok { + return "", fmt.Errorf("build_id not found in server response") + } + + return buildID, nil +} + +// killExistingServer kills the existing server process +func killExistingServer() error { + // Read PID from PID file + pidFile := filepath.Join(os.Getenv("HOME"), ".gbox", "cli", "gbox-server.pid") + pidData, err := os.ReadFile(pidFile) + if err != nil { + // PID file doesn't exist, try to find process by port + return killServerByPort() + } + + pid := strings.TrimSpace(string(pidData)) + if pid == "" { + // Empty PID file, try to find process by port + return killServerByPort() + } + + // Convert PID to int + pidInt, err := strconv.Atoi(pid) + if err != nil { + // Invalid PID, try to find process by port + return killServerByPort() + } + + // Try to kill the process by PID first + if err := killProcessByPID(pidInt); err != nil { + // If PID-based kill fails, try port-based kill + return killServerByPort() + } + + // Remove PID file + os.Remove(pidFile) + + return nil +} + +// killProcessByPID kills a process by its PID +func killProcessByPID(pid int) error { + process, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("failed to find process %d: %v", pid, err) + } + + // Send SIGTERM first + if err := process.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("failed to send SIGTERM to process %d: %v", pid, err) + } + + // Wait for graceful shutdown + for i := 0; i < 10; i++ { + time.Sleep(500 * time.Millisecond) + // Check if process is still running + if err := process.Signal(syscall.Signal(0)); err != nil { + // Process is dead + return nil + } + } + + // Process still running, force kill + if err := process.Signal(syscall.SIGKILL); err != nil { + return fmt.Errorf("failed to send SIGKILL to process %d: %v", pid, err) + } + + // Wait a bit more for SIGKILL to take effect + time.Sleep(1 * time.Second) + + return nil +} + +// killServerByPort kills the server process by finding it via port +func killServerByPort() error { + // Use lsof to find the process using port 29888 + cmd := exec.Command("lsof", "-ti:29888") + output, err := cmd.Output() + if err != nil { + // No process found on port, that's fine + return nil + } + + pids := strings.Fields(string(output)) + for _, pidStr := range pids { + pid, err := strconv.Atoi(pidStr) + if err != nil { + continue + } + + // Kill the process + if err := killProcessByPID(pid); err != nil { + fmt.Printf("Warning: failed to kill process %d: %v\n", pid, err) + } + } + + return nil +} + +// startServerInBackground starts the server in background mode with IPC communication +func startServerInBackground() error { + // Create a pipe for IPC communication + reader, writer, err := os.Pipe() + if err != nil { + return fmt.Errorf("failed to create pipe: %v", err) + } + defer reader.Close() + defer writer.Close() + + // Get the current executable path + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %v", err) + } + + // Create command to start server in background with reply fd + cmd := exec.Command(execPath, "server", "start", "--reply-fd", "3") + + // Set up process attributes for daemon mode + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, // Create new process group + } + + // Pass the write end of the pipe as file descriptor 3 + cmd.ExtraFiles = []*os.File{writer} + + // Redirect output to log file + homeDir, _ := os.UserHomeDir() + gboxDir := filepath.Join(homeDir, ".gbox", "cli") + logFile := filepath.Join(gboxDir, "server.log") + + logFileHandle, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("failed to open log file: %v", err) + } + defer logFileHandle.Close() + + cmd.Stdout = logFileHandle + cmd.Stderr = logFileHandle + + // Start the process + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start server: %v", err) + } + + // Close the write end in parent process + writer.Close() + + // Read the reply from the child process + replyChan := make(chan error, 1) + go func() { + buffer := make([]byte, 1024) + n, err := reader.Read(buffer) + if err != nil { + replyChan <- fmt.Errorf("failed to read reply: %v", err) + return + } + + reply := string(buffer[:n]) + if reply == "OK" { + replyChan <- nil + } else { + replyChan <- fmt.Errorf("server startup failed: %s", reply) + } + }() + + // Wait for reply with timeout + select { + case err := <-replyChan: + if err != nil { + // Server failed to start, clean up the process + cmd.Process.Kill() + return err + } + case <-time.After(10 * time.Second): + // Timeout waiting for reply + cmd.Process.Kill() + return fmt.Errorf("timeout waiting for server startup reply") + } + + // Write PID to file + pidFile := filepath.Join(gboxDir, "gbox-server.pid") + pidFileHandle, err := os.OpenFile(pidFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to create PID file: %v", err) + } + defer pidFileHandle.Close() + + if _, err := pidFileHandle.WriteString(strconv.Itoa(cmd.Process.Pid)); err != nil { + return fmt.Errorf("failed to write PID file: %v", err) + } + + return nil +} + +// createGBOXClient creates a GBOX client for API calls +func createGBOXClient() (*sdk.Client, error) { + return gboxsdk.NewClientFromProfile() +} + +// checkBoxExists checks if a box exists using the GBOX API +func checkBoxExists(boxID string) error { + // Create a client to check if the box exists + client, err := createGBOXClient() + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + + // Check if box exists + box, err := gboxsdk.GetBox(client, boxID) + if err != nil { + // If we can't get the box, it might not exist + return fmt.Errorf("box %s does not exist or is not accessible", boxID) + } + + // Check if box is running + if box.Status != "running" { + return fmt.Errorf("box %s is not running (status: %s)", boxID, box.Status) + } + + return nil +} \ No newline at end of file diff --git a/packages/cli/internal/adb_expose/multiplex_client.go b/packages/cli/internal/adb_expose/multiplex_client.go new file mode 100644 index 00000000..3599b83b --- /dev/null +++ b/packages/cli/internal/adb_expose/multiplex_client.go @@ -0,0 +1,291 @@ +package adb_expose + +import ( + "encoding/binary" + "fmt" + "log" + "net" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + TypeOpen = iota + 1 + TypeData + TypeClose + TypeError + TypeAck +) + +type Config struct { + APIKey string + BoxID string + GboxURL string + LocalAddr string + TargetPorts []int +} + +type PortForwardRequest struct { + Ports []int `json:"ports"` +} + +type PortForwardResponse struct { + URL string `json:"url"` +} + +type Stream struct { + id uint32 + localConn net.Conn + closeCh chan struct{} + readyCh chan struct{} + mu sync.Mutex + closed bool + ready bool +} + +type MultiplexClient struct { + ws *websocket.Conn + streams map[uint32]*Stream + mu sync.RWMutex + nextID uint32 + muID sync.Mutex + closeCh chan struct{} + writeMu sync.Mutex +} + +func NewMultiplexClient(ws *websocket.Conn) *MultiplexClient { + return &MultiplexClient{ + ws: ws, + streams: make(map[uint32]*Stream), + closeCh: make(chan struct{}), + } +} + +func (m *MultiplexClient) Close() { + select { + case <-m.closeCh: + default: + close(m.closeCh) + } + + m.mu.Lock() + defer m.mu.Unlock() + + for _, stream := range m.streams { + stream.Close() + } + m.streams = nil +} + +func (m *MultiplexClient) Run() error { + for { + select { + case <-m.closeCh: + return nil + default: + messageType, data, err := m.ws.ReadMessage() + if err != nil { + return fmt.Errorf("websocket read error: %v", err) + } + + if messageType != websocket.BinaryMessage { + continue + } + + msgType, streamID, payload, err := parseMessage(data) + if err != nil { + log.Printf("parse message error: %v", err) + continue + } + + switch msgType { + case TypeData: + m.HandleData(streamID, payload) + case TypeClose: + m.HandleClose(streamID) + case TypeError: + m.HandleError(streamID, payload) + case TypeAck: + m.HandleAck(streamID) + default: + log.Printf("unknown message type: %d", msgType) + } + } + } +} + +func (m *MultiplexClient) HandleData(streamID uint32, payload []byte) { + m.mu.RLock() + stream, exists := m.streams[streamID] + m.mu.RUnlock() + + if !exists { + log.Printf("stream %d not found", streamID) + return + } + + _, err := stream.localConn.Write(payload) + if err != nil { + log.Printf("localConn.Write error: %v", err) + stream.Close() + m.RemoveStream(streamID) + } +} + +func (m *MultiplexClient) HandleClose(streamID uint32) { + m.mu.RLock() + stream, exists := m.streams[streamID] + m.mu.RUnlock() + + if exists { + stream.Close() + m.RemoveStream(streamID) + } +} + +func (m *MultiplexClient) HandleError(streamID uint32, payload []byte) { + log.Printf("server error for stream %d: %s", streamID, string(payload)) + m.HandleClose(streamID) +} + +func (m *MultiplexClient) HandleAck(streamID uint32) { + m.mu.RLock() + stream, exists := m.streams[streamID] + m.mu.RUnlock() + + if !exists { + log.Printf("received ack for unknown stream %d", streamID) + return + } + + stream.mu.Lock() + if !stream.ready { + stream.ready = true + close(stream.readyCh) + } + stream.mu.Unlock() +} + +func (m *MultiplexClient) NewStreamID() uint32 { + m.muID.Lock() + defer m.muID.Unlock() + // client use even id, server use odd id + // if future need client to access server, server use odd id + m.nextID += 2 + return m.nextID +} + +func (m *MultiplexClient) SendMessage(msgType byte, streamID uint32, payload []byte) error { + m.writeMu.Lock() + defer m.writeMu.Unlock() + + message := make([]byte, 5+len(payload)) + message[0] = msgType + binary.BigEndian.PutUint32(message[1:5], streamID) + copy(message[5:], payload) + + return m.ws.WriteMessage(websocket.BinaryMessage, message) +} + +func (m *MultiplexClient) HandleStream(stream *Stream) { + defer func() { + stream.Close() + m.RemoveStream(stream.id) + }() + + select { + case <-stream.readyCh: + case <-stream.closeCh: + return + case <-time.After(10 * time.Second): + log.Printf("timeout waiting for server ack for stream %d", stream.id) + m.SendMessage(TypeClose, stream.id, nil) + return + } + + buf := make([]byte, 4096) + for { + select { + case <-stream.closeCh: + return + default: + n, err := stream.localConn.Read(buf) + if err != nil { + m.SendMessage(TypeClose, stream.id, nil) + return + } + + err = m.SendMessage(TypeData, stream.id, buf[:n]) + if err != nil { + log.Printf("sendMessage error: %v", err) + return + } + } + } +} + +func (m *MultiplexClient) RemoveStream(streamID uint32) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.streams, streamID) +} + +func (m *MultiplexClient) AddStream(streamID uint32, localConn net.Conn) *Stream { + stream := &Stream{ + id: streamID, + localConn: localConn, + closeCh: make(chan struct{}), + readyCh: make(chan struct{}), + ready: false, + } + + m.mu.Lock() + if m.streams == nil { + m.mu.Unlock() + log.Printf("streams map is nil, client may be closed") + stream.Close() + return stream + } + m.streams[streamID] = stream + m.mu.Unlock() + + go m.HandleStream(stream) + + return stream +} + +func (s *Stream) Close() { + s.mu.Lock() + defer s.mu.Unlock() + if !s.closed { + s.closed = true + close(s.closeCh) + if !s.ready { + close(s.readyCh) + } + s.localConn.Close() + } +} + +func HandleLocalConnWithClient(localConn net.Conn, client *MultiplexClient, remotePort int) { + defer func() { + localConn.Close() + }() + + streamID := client.NewStreamID() + stream := client.AddStream(streamID, localConn) + + // start multiplexing + // the payload is : + // remote server limit the ip, so we use any valid ip as payload + // And the must be in the port-forward-url response, remote server will check it + err := client.SendMessage(TypeOpen, streamID, []byte(fmt.Sprintf("127.0.0.1:%d", remotePort))) + if err != nil { + log.Printf("send open message error: %v", err) + return + } + + <-stream.closeCh +} diff --git a/packages/cli/internal/adb_expose/utils.go b/packages/cli/internal/adb_expose/utils.go index e5f21027..3bdccdcc 100644 --- a/packages/cli/internal/adb_expose/utils.go +++ b/packages/cli/internal/adb_expose/utils.go @@ -7,133 +7,15 @@ import ( "fmt" "io" "net/http" - "os" - "path/filepath" "strconv" "strings" - "syscall" "time" - "github.com/babelcloud/gbox/packages/cli/config" "github.com/gorilla/websocket" ) -// PidInfo holds info for a running port-forward process -// Support multiple ports in a single process -type PidInfo struct { - Pid int `json:"pid"` - BoxID string `json:"boxid"` - LocalPorts []int `json:"localports"` - RemotePorts []int `json:"remoteports"` - StartedAt time.Time `json:"started_at"` -} - -func ensureGboxDir() error { - dir := config.GetGboxHome() - return os.MkdirAll(dir, 0700) -} - -const pidFileNamePrefix = "gbox-adb-expose-" -const pidFileNameSuffix = ".pid" -const logFileNameSuffix = ".log" - -func pidFilePath(boxId string, localPort int) string { - return config.GetGboxHome() + "/" + pidFileNamePrefix + boxId + "-" + strconv.Itoa(localPort) + pidFileNameSuffix -} - -func logFilePath(boxId string, localPort int) string { - return config.GetGboxHome() + "/" + pidFileNamePrefix + boxId + "-" + strconv.Itoa(localPort) + logFileNameSuffix -} - -const pidFilePattern = "gbox-adb-expose-*.pid" - -// WritePidFile writes a pid file for multiple ports (first local port is used for file name) -func WritePidFile(boxId string, localPorts, remotePorts []int) error { - if err := ensureGboxDir(); err != nil { - return err - } - // Use the first local port for the pid file name - path := pidFilePath(boxId, localPorts[0]) - // check if pid file exists - if _, err := os.Stat(path); err == nil { - f, err := os.Open(path) - if err == nil { - var info PidInfo - decodeErr := json.NewDecoder(f).Decode(&info) - f.Close() - if decodeErr == nil && IsProcessAlive(info.Pid) { - return fmt.Errorf("adb-expose already running for boxId=%s, localPort=%d (pid=%d)", boxId, localPorts[0], info.Pid) - } - } - } - info := PidInfo{ - Pid: os.Getpid(), - BoxID: boxId, - LocalPorts: localPorts, - RemotePorts: remotePorts, - StartedAt: time.Now(), - } - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - enc := json.NewEncoder(f) - return enc.Encode(&info) -} - -// RemovePidFile removes the pid file for a given local port -func RemovePidFile(boxId string, localPort int) error { - return os.Remove(pidFilePath(boxId, localPort)) -} - -func RemoveLogFile(boxId string, localPort int) error { - return os.Remove(logFilePath(boxId, localPort)) -} - -func ListPidFiles() ([]PidInfo, error) { - dir := config.GetGboxHome() - files, err := filepath.Glob(dir + "/" + pidFilePattern) - if err != nil { - return nil, err - } - var infos []PidInfo - for _, f := range files { - file, err := os.Open(f) - if err != nil { - continue - } - var info PidInfo - err = json.NewDecoder(file).Decode(&info) - file.Close() - if err == nil { - infos = append(infos, info) - } - } - return infos, nil -} - -func IsProcessAlive(pid int) bool { - if pid <= 0 { - return false - } - proc, err := os.FindProcess(pid) - if err != nil { - return false - } - // Signal 0 does not kill the process, just checks existence - return proc.Signal(syscall.Signal(0)) == nil -} - -func FindPidFile(boxId string, localPort int) (string, error) { - path := pidFilePath(boxId, localPort) - _, err := os.Stat(path) - if err != nil { - return "", err - } - return path, nil -} +// getPortForwardURL gets the WebSocket URL for port forwarding func getPortForwardURL(config Config) (string, error) { url := fmt.Sprintf("%s/boxes/%s/port-forward-url", config.GboxURL, config.BoxID) @@ -181,6 +63,7 @@ func getPortForwardURL(config Config) (string, error) { return response.URL, nil } +// ConnectWebSocket creates a WebSocket connection for port forwarding func ConnectWebSocket(config Config) (*MultiplexClient, error) { wsURL, err := getPortForwardURL(config) if err != nil { @@ -196,11 +79,7 @@ func ConnectWebSocket(config Config) (*MultiplexClient, error) { return client, nil } -// PrintStartupMessage prints the startup message for adb-expose -func PrintStartupMessage(pid int, logPath string, boxID string) { - fmt.Printf("[gbox] Adb-expose started in background for box %s (pid=%d). Logs: %s\n\nUse 'gbox adb-expose list' to view, 'gbox adb-expose stop %s' to stop.\n", boxID, pid, logPath, boxID) -} - +// parseMessage parses a multiplexing protocol message func parseMessage(data []byte) (msgType byte, streamID uint32, payload []byte, err error) { if len(data) < 5 { return 0, 0, nil, fmt.Errorf("message too short") @@ -213,30 +92,36 @@ func parseMessage(data []byte) (msgType byte, streamID uint32, payload []byte, e return msgType, streamID, payload, nil } -// PrepareGBOXEnvironment prepares environment variables for daemon process -// ensuring important GBOX environment variables are preserved -func PrepareGBOXEnvironment() []string { - env := os.Environ() - - // Ensure important GBOX environment variables are passed to child process - // This ensures the child has the same configuration context as parent - for _, envVar := range []string{"GBOX_BASE_URL", "GBOX_API_KEY", "GBOX_HOME"} { - if value := os.Getenv(envVar); value != "" { - // Check if already in environment, if not add it - found := false - prefix := envVar + "=" - for i, existing := range env { - if strings.HasPrefix(existing, prefix) { - env[i] = envVar + "=" + value - found = true - break - } - } - if !found { - env = append(env, envVar+"="+value) - } +// ParsePorts parses a comma-separated string of ports +func ParsePorts(portStr string) ([]int, error) { + if portStr == "" { + return nil, fmt.Errorf("no ports specified") + } + + parts := strings.Split(portStr, ",") + ports := make([]int, 0, len(parts)) + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + port, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("invalid port '%s': %v", part, err) } + + if port <= 0 || port > 65535 { + return nil, fmt.Errorf("port %d is out of range (1-65535)", port) + } + + ports = append(ports, port) } - return env -} + if len(ports) == 0 { + return nil, fmt.Errorf("no valid ports specified") + } + + return ports, nil +} \ No newline at end of file diff --git a/packages/cli/internal/adb_expose/utils_unix.go b/packages/cli/internal/adb_expose/utils_unix.go deleted file mode 100644 index 42c648ce..00000000 --- a/packages/cli/internal/adb_expose/utils_unix.go +++ /dev/null @@ -1,82 +0,0 @@ -//go:build !windows - -package adb_expose - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "syscall" -) - -// DaemonizeIfNeeded forks to background if foreground==false and not already daemonized. -// logPath: if not empty, background process logs to this file. -// boxID: the box ID for startup message. -// fromInteractive: indicates if this is called from interactive mode. -// Returns (shouldReturn, err): if shouldReturn==true, caller should return immediately (parent process or error). -func DaemonizeIfNeeded(foreground bool, logPath string, boxID string, fromInteractive bool) (bool, error) { - if foreground || os.Getenv("GBOX_ADB_EXPOSE_DAEMON") != "" { - return false, nil - } - // open log file - logFile := os.Stdout - if logPath != "" { - f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - return true, fmt.Errorf("failed to open log file: %v", err) - } - logFile = f - defer f.Close() - } - - // Prepare environment for child process with GBOX environment variables preserved - env := PrepareGBOXEnvironment() - env = append(env, "GBOX_ADB_EXPOSE_DAEMON=1") - - attr := &os.ProcAttr{ - Dir: "", - Env: env, - Files: []*os.File{os.Stdin, logFile, logFile}, - Sys: &syscall.SysProcAttr{Setsid: true}, - } - // For daemon mode, determine the command based on context - var args []string - if fromInteractive { - // If called from interactive mode, use start subcommand - args = []string{os.Args[0], "adb-expose", "start", boxID} - } else { - // If called from start subcommand, use the same command but with daemon flag - args = os.Args - } - // Remove -f/--foreground from args if present - newArgs := []string{} - for i := 0; i < len(args); i++ { - if args[i] == "-f" || args[i] == "--foreground" { - continue - } - newArgs = append(newArgs, args[i]) - } - - // Resolve executable path robustly (PATH lookup + recursive symlink resolution) - execPath := args[0] - if !filepath.IsAbs(execPath) { - if lp, err := exec.LookPath(execPath); err == nil { - execPath = lp - } - } - if abs, err := filepath.Abs(execPath); err == nil { - if resolved, err := filepath.EvalSymlinks(abs); err == nil { - execPath = resolved - } else { - execPath = abs - } - } - - proc, err := os.StartProcess(execPath, newArgs, attr) - if err != nil { - return true, fmt.Errorf("failed to daemonize: %v", err) - } - PrintStartupMessage(proc.Pid, logPath, boxID) - return true, nil -} diff --git a/packages/cli/internal/adb_expose/utils_windows.go b/packages/cli/internal/adb_expose/utils_windows.go deleted file mode 100644 index cc25129e..00000000 --- a/packages/cli/internal/adb_expose/utils_windows.go +++ /dev/null @@ -1,81 +0,0 @@ -//go:build windows - -package adb_expose - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "syscall" -) - -// DaemonizeIfNeeded forks to background if foreground==false and not already daemonized. -// logPath: if not empty, background process logs to this file. -// boxID: the box ID for startup message. -// fromInteractive: indicates if this is called from interactive mode. -// Returns (shouldReturn, err): if shouldReturn==true, caller should return immediately (parent process or error). -func DaemonizeIfNeeded(foreground bool, logPath string, boxID string, fromInteractive bool) (bool, error) { - if foreground || os.Getenv("GBOX_ADB_EXPOSE_DAEMON") != "" { - return false, nil - } - // open log file - logFile := os.Stdout - if logPath != "" { - f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - return true, fmt.Errorf("failed to open log file: %v", err) - } - logFile = f - defer f.Close() - } - // Prepare environment for child process with GBOX environment variables preserved - env := PrepareGBOXEnvironment() - env = append(env, "GBOX_ADB_EXPOSE_DAEMON=1") - - attr := &os.ProcAttr{ - Dir: "", - Env: env, - Files: []*os.File{os.Stdin, logFile, logFile}, - Sys: &syscall.SysProcAttr{}, - } - // For daemon mode, determine the command based on context - var args []string - if fromInteractive { - // If called from interactive mode, use start subcommand - args = []string{os.Args[0], "adb-expose", "start", boxID} - } else { - // If called from start subcommand, use the same command but with daemon flag - args = os.Args - } - // Remove -f/--foreground from args if present - newArgs := []string{} - for i := 0; i < len(args); i++ { - if args[i] == "-f" || args[i] == "--foreground" { - continue - } - newArgs = append(newArgs, args[i]) - } - - // Resolve executable path robustly (PATH lookup + recursive symlink resolution) - execPath := args[0] - if !filepath.IsAbs(execPath) { - if lp, err := exec.LookPath(execPath); err == nil { - execPath = lp - } - } - if abs, err := filepath.Abs(execPath); err == nil { - if resolved, err := filepath.EvalSymlinks(abs); err == nil { - execPath = resolved - } else { - execPath = abs - } - } - - proc, err := os.StartProcess(execPath, newArgs, attr) - if err != nil { - return true, fmt.Errorf("failed to daemonize: %v", err) - } - PrintStartupMessage(proc.Pid, logPath, boxID) - return true, nil -} diff --git a/packages/cli/internal/device_connect/client.go b/packages/cli/internal/device_connect/client.go deleted file mode 100644 index 24b1822e..00000000 --- a/packages/cli/internal/device_connect/client.go +++ /dev/null @@ -1,189 +0,0 @@ -package device_connect - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "time" -) - -const ( - DefaultPort = 19925 - DefaultURL = "http://localhost:19925" -) - -// DeviceInfo represents a device from the API -type DeviceInfo struct { - Id string `json:"id"` - Udid string `json:"udid"` - State string `json:"state"` - Interfaces []struct { - Name string `json:"name"` - Ipv4 string `json:"ipv4"` - } `json:"interfaces"` - Pid int `json:"pid"` - BuildVersionRelease string `json:"ro.build.version.release"` - BuildVersionSdk string `json:"ro.build.version.sdk"` - ProductManufacturer string `json:"ro.product.manufacturer"` - ProductModel string `json:"ro.product.model"` - ProductCpuAbi string `json:"ro.product.cpu.abi"` - SerialNo string `json:"ro.serialno"` - LastUpdateTimestamp int64 `json:"last.update.timestamp"` - ConnectionType string `json:"connectionType"` - IsRegistrable bool `json:"isRegistrable"` -} - -// DeviceListResponse represents the response from GET /api/devices -type DeviceListResponse struct { - Success bool `json:"success"` - Devices []DeviceInfo `json:"devices"` - OnDemandEnabled bool `json:"onDemandEnabled"` - Error string `json:"error,omitempty"` -} - -// DeviceActionResponse represents the response from POST /api/devices/register and /api/devices/unregister -type DeviceActionResponse struct { - Success bool `json:"success"` - Message string `json:"message,omitempty"` - DeviceID string `json:"device_id,omitempty"` - Error string `json:"error,omitempty"` -} - -// Client represents a device proxy API client -type Client struct { - baseURL string - httpClient *http.Client -} - -// NewClient creates a new device proxy API client -func NewClient(baseURL string) *Client { - return &Client{ - baseURL: baseURL, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -// IsServiceRunning checks if the device proxy service is running and returns onDemandEnabled status -func (c *Client) IsServiceRunning() (bool, bool, error) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/devices", c.baseURL), nil) - if err != nil { - return false, false, err - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return false, false, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return false, false, fmt.Errorf("service returned status code: %d", resp.StatusCode) - } - - // Parse response to check onDemandEnabled status - var deviceListResp DeviceListResponse - if err := json.NewDecoder(resp.Body).Decode(&deviceListResp); err != nil { - return false, false, fmt.Errorf("failed to parse response: %v", err) - } - - if !deviceListResp.Success { - return false, false, fmt.Errorf("API returned success: false") - } - - return true, deviceListResp.OnDemandEnabled, nil -} - -// GetDevices retrieves all available devices from the API -func (c *Client) GetDevices() ([]DeviceInfo, error) { - resp, err := c.httpClient.Get(fmt.Sprintf("%s/api/devices", c.baseURL)) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var deviceListResp DeviceListResponse - if err := json.NewDecoder(resp.Body).Decode(&deviceListResp); err != nil { - return nil, err - } - - if !deviceListResp.Success { - return nil, fmt.Errorf("API error: %s", deviceListResp.Error) - } - - return deviceListResp.Devices, nil -} - -// GetDeviceInfo retrieves information about a specific device -func (c *Client) GetDeviceInfo(deviceID string) (*DeviceInfo, error) { - devices, err := c.GetDevices() - if err != nil { - return nil, err - } - - for _, device := range devices { - if device.Id == deviceID { - return &device, nil - } - } - - return nil, fmt.Errorf("device not found: %s", deviceID) -} - -// RegisterDevice registers a device for remote access -func (c *Client) RegisterDevice(deviceID string) error { - data := map[string]string{"deviceId": deviceID} - jsonData, err := json.Marshal(data) - if err != nil { - return err - } - - resp, err := c.httpClient.Post(fmt.Sprintf("%s/api/devices/register", c.baseURL), "application/json", bytes.NewBuffer(jsonData)) - if err != nil { - return err - } - defer resp.Body.Close() - - var deviceActionResp DeviceActionResponse - if err := json.NewDecoder(resp.Body).Decode(&deviceActionResp); err != nil { - return err - } - - if !deviceActionResp.Success { - return fmt.Errorf("failed to register device: %s", deviceActionResp.Error) - } - - return nil -} - -// UnregisterDevice unregisters a device -func (c *Client) UnregisterDevice(deviceID string) error { - data := map[string]string{"deviceId": deviceID} - jsonData, err := json.Marshal(data) - if err != nil { - return err - } - - resp, err := c.httpClient.Post(fmt.Sprintf("%s/api/devices/unregister", c.baseURL), "application/json", bytes.NewBuffer(jsonData)) - if err != nil { - return err - } - defer resp.Body.Close() - - var deviceActionResp DeviceActionResponse - if err := json.NewDecoder(resp.Body).Decode(&deviceActionResp); err != nil { - return err - } - - if !deviceActionResp.Success { - return fmt.Errorf("failed to unregister device: %s", deviceActionResp.Error) - } - - return nil -} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/daemon.go b/packages/cli/internal/device_connect/daemon.go deleted file mode 100644 index 0f344c58..00000000 --- a/packages/cli/internal/device_connect/daemon.go +++ /dev/null @@ -1,325 +0,0 @@ -package device_connect - -import ( - "fmt" - "net/url" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - "github.com/babelcloud/gbox/packages/cli/config" -) - -// isExecutableFile checks if the given path is an executable file (not a directory) -func isExecutableFile(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - - // Check if it's a directory - if info.IsDir() { - return false - } - - // Check if it has execute permissions - mode := info.Mode() - return mode&0111 != 0 // Check if any execute bit is set -} - -// EnsureDeviceProxyRunning checks if the service is running, and starts it if not -func EnsureDeviceProxyRunning(isServiceRunning func() (bool, error)) error { - running, err := isServiceRunning() - if err != nil { - return StartDeviceProxyService() - } - if running { - return nil - } - return StartDeviceProxyService() -} - -func FindDeviceProxyBinary() (string, error) { - currentDir, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("failed to get current directory: %v", err) - } - executablePath, err := os.Executable() - if err != nil { - return "", fmt.Errorf("failed to get executable path: %v", err) - } - executableDir := filepath.Dir(executablePath) - osName := runtime.GOOS - arch := runtime.GOARCH - - // Map runtime.GOOS to directory name format - dirOsName := osName - if osName == "darwin" { - dirOsName = "macos" - } - - binaryName := "gbox-device-proxy" - if osName == "windows" { - binaryName += ".exe" - } - - debug := os.Getenv("DEBUG") == "true" - - // Priority 1: Check current directory first - currentBinaryPath := filepath.Join(currentDir, binaryName) - if isExecutableFile(currentBinaryPath) { - if debug { - fmt.Fprintf(os.Stderr, "[DEBUG] Found gbox-device-proxy binary in current directory: %s\n", currentBinaryPath) - } - return currentBinaryPath, nil - } - - // Priority 2: Check babel-umbrella directory - babelUmbrellaPath := FindBabelUmbrellaDir(currentDir) - if babelUmbrellaPath != "" { - binariesDir := filepath.Join(babelUmbrellaPath, "gbox-device-proxy", "build", fmt.Sprintf("binaries-%s-%s", dirOsName, arch)) - babelBinaryPath := filepath.Join(binariesDir, binaryName) - if debug { - fmt.Fprintf(os.Stderr, "[DEBUG] Checking babel-umbrella path: %s\n", babelBinaryPath) - } - if isExecutableFile(babelBinaryPath) { - if debug { - fmt.Fprintf(os.Stderr, "[DEBUG] Found gbox-device-proxy binary in babel-umbrella: %s\n", babelBinaryPath) - } - return babelBinaryPath, nil - } - } - - // Priority 3: Check device proxy home directory (where we download binaries) - deviceProxyHome := config.GetDeviceProxyHome() - deviceProxyBinaryPath := filepath.Join(deviceProxyHome, binaryName) - if debug { - fmt.Fprintf(os.Stderr, "[DEBUG] Checking device proxy home: %s\n", deviceProxyBinaryPath) - } - if isExecutableFile(deviceProxyBinaryPath) { - if debug { - fmt.Fprintf(os.Stderr, "[DEBUG] Found gbox-device-proxy binary in device proxy home: %s\n", deviceProxyBinaryPath) - } - - // Use the new version-aware download function - // This will check if we need to update and download if necessary - binaryPath, err := CheckAndDownloadDeviceProxy() - if err != nil { - if debug { - fmt.Printf("Warning: Failed to check/download device proxy: %v\n", err) - } - // Return existing binary if download fails - return deviceProxyBinaryPath, nil - } - - return binaryPath, nil - } - - // Priority 4: Check PATH - if path, err := exec.LookPath("gbox-device-proxy"); err == nil { - if debug { - fmt.Fprintf(os.Stderr, "[DEBUG] Found gbox-device-proxy binary in PATH: %s\n", path) - } - return path, nil - } - - // Fallback: Search in directory hierarchy (current and executable directories) - searchPaths := []string{} - // Search in current directory hierarchy - current := currentDir - for { - searchPaths = append(searchPaths, filepath.Join(current, binaryName)) - parent := filepath.Dir(current) - if parent == current { - break // Reached root directory - } - current = parent - } - // Search in executable directory hierarchy - execCurrent := executableDir - for { - searchPaths = append(searchPaths, filepath.Join(execCurrent, binaryName)) - parent := filepath.Dir(execCurrent) - if parent == execCurrent { - break // Reached root directory - } - execCurrent = parent - } - for _, path := range searchPaths { - if isExecutableFile(path) { - if debug { - fmt.Fprintf(os.Stderr, "[DEBUG] Found gbox-device-proxy binary in fallback search: %s\n", path) - } - return path, nil - } - } - - // Final fallback: Try to download from gbox Releases (public), then fallback to private repo - fmt.Fprintf(os.Stderr, "gbox-device-proxy binary not found. Attempting to download from gbox Releases...\n") - - downloadedPath, err := DownloadDeviceProxy() - if err != nil { - return "", fmt.Errorf("gbox-device-proxy binary not found and download failed: %v", err) - } - - // Run version command after download and print it to console in one line - versionCmd := exec.Command(downloadedPath, "--version") - versionCmd.Env = os.Environ() - if out, verr := versionCmd.CombinedOutput(); verr != nil { - fmt.Fprintf(os.Stderr, "Binary downloaded to: %s\n, but it's not executable: %v\n", downloadedPath, verr) - } else { - fmt.Fprintf(os.Stderr, "Successfully downloaded gbox-device-proxy to: %s version: %s.\n", downloadedPath, strings.TrimSpace(string(out))) - } - - return downloadedPath, nil -} - -func FindBabelUmbrellaDir(startDir string) string { - current := startDir - - // First, try to find babel-umbrella in the current path hierarchy - for { - if filepath.Base(current) == "babel-umbrella" { - return current - } - parent := filepath.Dir(current) - if parent == current { - break - } - current = parent - } - - // If not found in hierarchy, try the known relative path - knownPath := filepath.Join(startDir, "..", "..", "..", "babel-umbrella") - if _, err := os.Stat(knownPath); err == nil { - return knownPath - } - - return "" -} - -// setupDeviceProxyEnvironment sets up environment variables for device proxy service -func setupDeviceProxyEnvironment(apiKey, baseURL string) []string { - env := os.Environ() - env = append(env, "GBOX_PROVIDER_TYPE=org") - env = append(env, fmt.Sprintf("GBOX_API_KEY=%s", apiKey)) - - baseEndpoint := strings.TrimSuffix(baseURL, "/") - baseEndpoint = strings.TrimSuffix(baseEndpoint, "/api/v1") - - // Add ANDROID_DEVMGR_ENDPOINT environment variable - env = append(env, fmt.Sprintf("ANDROID_DEVMGR_ENDPOINT=%s/devmgr", baseEndpoint)) - - // Also add GBOX_BASE_URL for consistency - env = append(env, fmt.Sprintf("GBOX_BASE_URL=%s", baseEndpoint)) - - // Work around for frp not supporting no_proxy - env = handleNoProxyWorkaround(env, baseURL) - - return env -} - -// handleNoProxyWorkaround handles the case where frp doesn't support no_proxy -// If the target domain is in no_proxy list, remove proxy environment variables -func handleNoProxyWorkaround(env []string, baseURL string) []string { - // Parse baseURL to get domain - parsedURL, err := url.Parse(baseURL) - if err != nil { - // If we can't parse the URL, return original env unchanged - return env - } - - domain := parsedURL.Hostname() - if domain == "" { - return env - } - - // Check if domain is in no_proxy list - noProxyList := getNoProxyList() - if !isDomainInNoProxyList(domain, noProxyList) { - return env - } - - // Domain is in no_proxy list, remove proxy environment variables - return removeProxyEnvironmentVariables(env) -} - -// getNoProxyList gets the no_proxy list from environment variables -func getNoProxyList() []string { - noProxy := os.Getenv("no_proxy") - if noProxy == "" { - noProxy = os.Getenv("NO_PROXY") - } - - if noProxy == "" { - return nil - } - - // Split by comma and trim spaces - domains := strings.Split(noProxy, ",") - var result []string - for _, domain := range domains { - trimmed := strings.TrimSpace(domain) - if trimmed != "" { - result = append(result, trimmed) - } - } - - return result -} - -// isDomainInNoProxyList checks if a domain matches any pattern in no_proxy list -func isDomainInNoProxyList(domain string, noProxyList []string) bool { - for _, pattern := range noProxyList { - if matchesNoProxyPattern(domain, pattern) { - return true - } - } - return false -} - -// matchesNoProxyPattern checks if a domain matches a no_proxy pattern -// Supports exact match and wildcard (*.example.com) -func matchesNoProxyPattern(domain, pattern string) bool { - // Exact match - if domain == pattern { - return true - } - - // Wildcard pattern (*.example.com) - if strings.HasPrefix(pattern, "*.") { - suffix := pattern[2:] // Remove "*." - if strings.HasSuffix(domain, suffix) { - return true - } - } - - // Localhost and local domains - if pattern == "localhost" || pattern == "127.0.0.1" || pattern == "::1" { - if domain == "localhost" || domain == "127.0.0.1" || domain == "::1" { - return true - } - } - - return false -} - -// removeProxyEnvironmentVariables removes proxy-related environment variables -func removeProxyEnvironmentVariables(env []string) []string { - var result []string - - for _, envVar := range env { - // Only remove http_proxy and https_proxy (both lowercase and uppercase) - if strings.HasPrefix(envVar, "http_proxy=") || - strings.HasPrefix(envVar, "HTTP_PROXY=") || - strings.HasPrefix(envVar, "https_proxy=") || - strings.HasPrefix(envVar, "HTTPS_PROXY=") { - continue // Skip this environment variable - } - result = append(result, envVar) - } - - return result -} diff --git a/packages/cli/internal/device_connect/daemon_native.go b/packages/cli/internal/device_connect/daemon_native.go deleted file mode 100644 index dc34a9c4..00000000 --- a/packages/cli/internal/device_connect/daemon_native.go +++ /dev/null @@ -1,182 +0,0 @@ -//go:build !windows - -package device_connect - -import ( - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "strconv" - "syscall" - "time" - - "github.com/babelcloud/gbox/packages/cli/config" -) - -// serverInstance holds the global server instance -var serverInstance *Server - -// StartNativeDeviceProxyService starts the integrated scrcpy server -func StartNativeDeviceProxyService() error { - // Create device proxy home directory - deviceProxyHome := config.GetDeviceProxyHome() - if err := os.MkdirAll(deviceProxyHome, 0755); err != nil { - return fmt.Errorf("failed to create device proxy home directory: %v", err) - } - - // Check if already running - pidFile := filepath.Join(deviceProxyHome, "device-proxy.pid") - if pidBytes, err := os.ReadFile(pidFile); err == nil { - var pid int - if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err == nil { - // Check if process is still running - if err := syscall.Kill(pid, 0); err == nil { - return fmt.Errorf("device proxy service already running with PID %d", pid) - } - } - // Remove stale PID file - os.Remove(pidFile) - } - - // Fork the process to run in background - if os.Getenv("GBOX_DEVICE_PROXY_DAEMON") != "1" { - // Create log file - logFile := filepath.Join(deviceProxyHome, "device-proxy.log") - logFd, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - return fmt.Errorf("failed to create log file: %v", err) - } - defer logFd.Close() - - // Start the server as a subprocess using exec.Command - cmd := exec.Command(os.Args[0], "device-connect", "start-server") - cmd.Env = append(os.Environ(), "GBOX_DEVICE_PROXY_DAEMON=1") - cmd.Stdout = logFd - cmd.Stderr = logFd - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setsid: true, - } - - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start daemon: %v", err) - } - - pid := cmd.Process.Pid - - // Write PID file - if err := os.WriteFile(pidFile, []byte(strconv.Itoa(pid)), 0644); err != nil { - return fmt.Errorf("failed to write PID file: %v", err) - } - - log.Printf("Started device proxy service with PID %d", pid) - - // Wait a bit to ensure server starts - time.Sleep(500 * time.Millisecond) - - // Verify server is responding - client := NewClient(DefaultURL) - for i := 0; i < 10; i++ { - if _, _, err := client.IsServiceRunning(); err == nil { - return nil - } - time.Sleep(500 * time.Millisecond) - } - - // If we get here, server didn't start properly - log.Printf("Warning: Server started but not responding on port %d", DefaultPort) - return nil - } - - // This is the daemon process - // Start the integrated device connect server - server := NewServer(DefaultPort) - serverInstance = server - - if err := server.Start(); err != nil { - return fmt.Errorf("failed to start device connect server: %v", err) - } - - // Write our PID - if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())), 0644); err != nil { - log.Printf("Warning: failed to write PID file: %v", err) - } - - // Keep the process running - select {} -} - -// StopNativeDeviceProxyService stops the integrated scrcpy server -func StopNativeDeviceProxyService() error { - deviceProxyHome := config.GetDeviceProxyHome() - pidFile := filepath.Join(deviceProxyHome, "device-proxy.pid") - - // Read PID from file - pidBytes, err := os.ReadFile(pidFile) - if err != nil { - return fmt.Errorf("device proxy service not running") - } - - var pid int - if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { - return fmt.Errorf("invalid PID file") - } - - // Send SIGTERM to the process - if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { - // Process might already be dead - os.Remove(pidFile) - return fmt.Errorf("failed to stop process: %v", err) - } - - // Remove PID file - os.Remove(pidFile) - - log.Printf("Stopped device proxy service (PID %d)", pid) - return nil -} - -// IsNativeServiceRunning checks if the native service is running -func IsNativeServiceRunning() (bool, error) { - // First check if PID file exists - deviceProxyHome := config.GetDeviceProxyHome() - pidFile := filepath.Join(deviceProxyHome, "device-proxy.pid") - - if _, err := os.Stat(pidFile); os.IsNotExist(err) { - return false, nil - } - - // Read PID from file - pidBytes, err := os.ReadFile(pidFile) - if err != nil { - return false, nil - } - - var pid int - if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { - return false, nil - } - - // Check if process is still running - if err := syscall.Kill(pid, 0); err != nil { - // Process is not running, remove PID file - os.Remove(pidFile) - return false, nil - } - - // Try to check service status via API - client := NewClient(DefaultURL) - running, onDemandEnabled, err := client.IsServiceRunning() - if err != nil { - // If API check fails but process exists, assume it's starting up - return true, nil - } - - // Check if onDemandEnabled is false and warn user - if running && !onDemandEnabled { - fmt.Println("Warning: Device proxy service is running with automatic registration enabled.") - } - - return running, nil -} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/daemon_test.go b/packages/cli/internal/device_connect/daemon_test.go deleted file mode 100644 index 42dbe6e6..00000000 --- a/packages/cli/internal/device_connect/daemon_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package device_connect - -import ( - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestIsExecutableFile(t *testing.T) { - // Test with a non-existent file - if isExecutableFile("/non/existent/file") { - t.Error("Non-existent file should not be considered executable") - } - - // Test with a directory - tempDir, err := os.MkdirTemp("", "gbox-test-*") - if err != nil { - t.Fatalf("Failed to create temp directory: %v", err) - } - defer os.RemoveAll(tempDir) - - if isExecutableFile(tempDir) { - t.Error("Directory should not be considered executable") - } - - // Test with a regular file - tempFile := filepath.Join(tempDir, "test.txt") - if err := os.WriteFile(tempFile, []byte("test"), 0644); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - - if isExecutableFile(tempFile) { - t.Error("Regular file should not be considered executable") - } - - // Test with an executable file (on Unix systems) - if runtime.GOOS != "windows" { - execFile := filepath.Join(tempDir, "test.sh") - if err := os.WriteFile(execFile, []byte("#!/bin/sh\necho test"), 0755); err != nil { - t.Fatalf("Failed to create executable file: %v", err) - } - - if !isExecutableFile(execFile) { - t.Error("Executable file should be considered executable") - } - } -} - -func TestFindBabelUmbrellaDir(t *testing.T) { - // Test with current directory (should not find babel-umbrella) - currentDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get current directory: %v", err) - } - - result := FindBabelUmbrellaDir(currentDir) - - // In the test environment, we might not have babel-umbrella - // So we just test that the function doesn't crash - if result != "" { - t.Logf("Found babel-umbrella directory: %s", result) - } else { - t.Log("No babel-umbrella directory found (expected in test environment)") - } -} - -func TestSetupDeviceProxyEnvironment(t *testing.T) { - apiKey := "test-api-key-12345" - baseURL := "https://test.example.com" - env := setupDeviceProxyEnvironment(apiKey, baseURL) - - // Check that required environment variables are set - found := false - for _, envVar := range env { - if contains(envVar, "GBOX_API_KEY="+apiKey) { - found = true - break - } - } - if !found { - t.Error("Expected GBOX_API_KEY to be set in environment") - } - - found = false - for _, envVar := range env { - if contains(envVar, "GBOX_PROVIDER_TYPE=org") { - found = true - break - } - } - if !found { - t.Error("Expected GBOX_PROVIDER_TYPE to be set in environment") - } - - found = false - for _, envVar := range env { - if contains(envVar, "ANDROID_DEVMGR_ENDPOINT=") { - found = true - break - } - } - if !found { - t.Error("Expected ANDROID_DEVMGR_ENDPOINT to be set in environment") - } - - found = false - for _, envVar := range env { - if contains(envVar, "GBOX_BASE_URL="+baseURL) { - found = true - break - } - } - if !found { - t.Error("Expected GBOX_BASE_URL to be set in environment") - } - - t.Logf("Environment variables set: %d", len(env)) -} - -func TestNoProxyWorkaround(t *testing.T) { - // Test case 1: Domain not in no_proxy list - env := []string{ - "http_proxy=http://proxy.example.com:8080", - "https_proxy=https://proxy.example.com:8080", - "no_proxy=localhost,127.0.0.1", - "PATH=/usr/bin", - } - - // Set environment variable for testing - os.Setenv("no_proxy", "localhost,127.0.0.1") - defer os.Unsetenv("no_proxy") - - result := handleNoProxyWorkaround(env, "https://gbox.ai") - - // Should keep proxy variables since gbox.ai is not in no_proxy - proxyFound := false - for _, envVar := range result { - if strings.HasPrefix(envVar, "http_proxy=") { - proxyFound = true - break - } - } - if !proxyFound { - t.Error("Expected http_proxy to be kept when domain not in no_proxy") - } - - // Test case 2: Domain in no_proxy list (localhost) - result = handleNoProxyWorkaround(env, "https://localhost:8080") - - // Should remove proxy variables since localhost is in no_proxy - proxyFound = false - for _, envVar := range result { - if strings.HasPrefix(envVar, "http_proxy=") { - proxyFound = true - break - } - } - if proxyFound { - t.Error("Expected http_proxy to be removed when domain in no_proxy") - } - - // Test case 3: Wildcard pattern - os.Setenv("no_proxy", "*.example.com,localhost") - env = []string{ - "http_proxy=http://proxy.example.com:8080", - "PATH=/usr/bin", - } - - result = handleNoProxyWorkaround(env, "https://api.example.com") - - // Should remove proxy variables since api.example.com matches *.example.com - proxyFound = false - for _, envVar := range result { - if strings.HasPrefix(envVar, "http_proxy=") { - proxyFound = true - break - } - } - if proxyFound { - t.Error("Expected http_proxy to be removed when domain matches wildcard pattern") - } - - // Test case 4: Check that only http_proxy and https_proxy are removed - os.Setenv("no_proxy", "localhost") - env = []string{ - "http_proxy=http://proxy.example.com:8080", - "https_proxy=https://proxy.example.com:8080", - "ftp_proxy=ftp://proxy.example.com:8080", - "all_proxy=socks5://proxy.example.com:8080", - "PATH=/usr/bin", - } - - result = handleNoProxyWorkaround(env, "https://localhost:8080") - - // Should only remove http_proxy and https_proxy, keep ftp_proxy and all_proxy - ftpProxyFound := false - allProxyFound := false - for _, envVar := range result { - if strings.HasPrefix(envVar, "ftp_proxy=") { - ftpProxyFound = true - } - if strings.HasPrefix(envVar, "all_proxy=") { - allProxyFound = true - } - } - if !ftpProxyFound { - t.Error("Expected ftp_proxy to be kept") - } - if !allProxyFound { - t.Error("Expected all_proxy to be kept") - } -} diff --git a/packages/cli/internal/device_connect/daemon_unix.go b/packages/cli/internal/device_connect/daemon_unix.go deleted file mode 100644 index c65875d3..00000000 --- a/packages/cli/internal/device_connect/daemon_unix.go +++ /dev/null @@ -1,76 +0,0 @@ -//go:build !windows - -package device_connect - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "syscall" - "time" - - "github.com/babelcloud/gbox/packages/cli/config" - "github.com/babelcloud/gbox/packages/cli/internal/profile" -) - -func StartDeviceProxyService() error { - binaryPath, err := FindDeviceProxyBinary() - if err != nil { - return fmt.Errorf("device proxy binary not found: %v", err) - } - - // Create device proxy home directory - deviceProxyHome := config.GetDeviceProxyHome() - if err := os.MkdirAll(deviceProxyHome, 0755); err != nil { - return fmt.Errorf("failed to create device proxy home directory: %v", err) - } - - // Create log file - logFile := filepath.Join(deviceProxyHome, "device-proxy.log") - logFd, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - return fmt.Errorf("failed to create log file: %v", err) - } - defer logFd.Close() - - // Create PID file path - pidFile := filepath.Join(deviceProxyHome, "device-proxy.pid") - - // Get API key from current profile - apiKey, err := profile.Default.GetEffectiveAPIKey() - if err != nil { - return fmt.Errorf("failed to get API key: %v", err) - } - - // Get base URL from profile - baseURL := profile.Default.GetEffectiveBaseURL() - - // Set up environment variables - env := setupDeviceProxyEnvironment(apiKey, baseURL) - - cmd := exec.Command(binaryPath, "--port", "19925", "--on-demand") - cmd.Stdout = logFd - cmd.Stderr = logFd - cmd.Env = env - - // Set process group to make child process independent - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setpgid: true, - } - - // Start the process - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start device proxy service: %v", err) - } - - // Write PID to file - if err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil { - // Try to kill the process if we can't write PID file - cmd.Process.Kill() - return fmt.Errorf("failed to write PID file: %v", err) - } - - time.Sleep(2 * time.Second) - return nil -} diff --git a/packages/cli/internal/device_connect/daemon_windows.go b/packages/cli/internal/device_connect/daemon_windows.go deleted file mode 100644 index 7499afeb..00000000 --- a/packages/cli/internal/device_connect/daemon_windows.go +++ /dev/null @@ -1,74 +0,0 @@ -//go:build windows - -package device_connect - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "syscall" - "time" - - "github.com/babelcloud/gbox/packages/cli/config" - "github.com/babelcloud/gbox/packages/cli/internal/profile" -) - -func StartDeviceProxyService() error { - binaryPath, err := FindDeviceProxyBinary() - if err != nil { - return fmt.Errorf("device proxy binary not found: %v", err) - } - - // Create device proxy home directory - deviceProxyHome := config.GetDeviceProxyHome() - if err := os.MkdirAll(deviceProxyHome, 0755); err != nil { - return fmt.Errorf("failed to create device proxy home directory: %v", err) - } - - // Create log file - logFile := filepath.Join(deviceProxyHome, "device-proxy.log") - logFd, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - return fmt.Errorf("failed to create log file: %v", err) - } - defer logFd.Close() - - // Create PID file path - pidFile := filepath.Join(deviceProxyHome, "device-proxy.pid") - - // Get API key from current profile - apiKey, err := profile.Default.GetEffectiveAPIKey() - if err != nil { - return fmt.Errorf("failed to get API key: %v", err) - } - - // Get base URL from profile - baseURL := profile.Default.GetEffectiveBaseURL() - - // Set up environment variables - env := setupDeviceProxyEnvironment(apiKey, baseURL) - - cmd := exec.Command(binaryPath, "--port", "19925", "--on-demand") - cmd.Stdout = logFd - cmd.Stderr = logFd - cmd.Env = env - - // Set process group to make child process independent (Windows doesn't support Setpgid) - cmd.SysProcAttr = &syscall.SysProcAttr{} - - // Start the process - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start device proxy service: %v", err) - } - - // Write PID to file - if err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil { - // Try to kill the process if we can't write PID file - cmd.Process.Kill() - return fmt.Errorf("failed to write PID file: %v", err) - } - - time.Sleep(2 * time.Second) - return nil -} diff --git a/packages/cli/internal/device_connect/device/connection.go b/packages/cli/internal/device_connect/device/connection.go index 7f98a6fc..6d4589fc 100644 --- a/packages/cli/internal/device_connect/device/connection.go +++ b/packages/cli/internal/device_connect/device/connection.go @@ -10,6 +10,8 @@ import ( "time" ) +// Note: assets will be embedded at build time using a different approach + // ScrcpyConnection handles the actual scrcpy server connection type ScrcpyConnection struct { deviceSerial string @@ -226,7 +228,9 @@ func (sc *ScrcpyConnection) Close() error { // findScrcpyServerJar finds the scrcpy-server.jar file func findScrcpyServerJar() string { - // Common locations to check + // Note: embedded assets will be handled differently + + // Fallback to external files locations := []string{ // In project assets directory (primary location) "./assets/scrcpy-server.jar", @@ -243,7 +247,6 @@ func findScrcpyServerJar() string { for _, path := range locations { if _, err := os.Stat(path); err == nil { absPath, _ := filepath.Abs(path) - log.Printf("Found scrcpy-server.jar at: %s", absPath) return absPath } } @@ -251,6 +254,8 @@ func findScrcpyServerJar() string { return "" } +// Note: embedded server extraction removed - using external files only + // logWriter implements io.Writer for logging type logWriter struct { prefix string diff --git a/packages/cli/internal/device_connect/downloader.go b/packages/cli/internal/device_connect/downloader.go deleted file mode 100644 index e7b16163..00000000 --- a/packages/cli/internal/device_connect/downloader.go +++ /dev/null @@ -1,678 +0,0 @@ -package device_connect - -import ( - "archive/tar" - "archive/zip" - "compress/gzip" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "runtime" - "strings" - "time" - - "github.com/babelcloud/gbox/packages/cli/config" - "github.com/babelcloud/gbox/packages/cli/internal/version" -) - -const ( - deviceProxyRepo = "babelcloud/gbox-device-proxy" - deviceProxyPublicRepo = "babelcloud/gbox" // Public repository for device-proxy assets - githubAPIURL = "https://api.github.com" -) - -// GitHubRelease represents a GitHub release -type GitHubRelease struct { - TagName string `json:"tag_name"` - Assets []struct { - Name string `json:"name"` - DownloadURL string `json:"browser_download_url"` - URL string `json:"url"` - } `json:"assets"` -} - -// VersionInfo represents version information -type VersionInfo struct { - TagName string `json:"tag_name"` - CommitID string `json:"commit_id"` - Downloaded string `json:"downloaded"` -} - -// getVersionCachePath returns the path to the version cache file -func getVersionCachePath() string { - deviceProxyHome := config.GetDeviceProxyHome() - return filepath.Join(deviceProxyHome, "version.json") -} - -// loadVersionInfo loads version information from cache -func loadVersionInfo() (*VersionInfo, error) { - cachePath := getVersionCachePath() - data, err := os.ReadFile(cachePath) - if err != nil { - return nil, err - } - - var info VersionInfo - if err := json.Unmarshal(data, &info); err != nil { - return nil, err - } - - return &info, nil -} - -// saveVersionInfo saves version information to cache -func saveVersionInfo(info *VersionInfo) error { - cachePath := getVersionCachePath() - deviceProxyHome := config.GetDeviceProxyHome() - - // Ensure directory exists - if err := os.MkdirAll(deviceProxyHome, 0755); err != nil { - return err - } - - data, err := json.MarshalIndent(info, "", " ") - if err != nil { - return err - } - - return os.WriteFile(cachePath, data, 0644) -} - -// CheckAndDownloadDeviceProxy checks if update is needed and downloads if necessary -func CheckAndDownloadDeviceProxy() (string, error) { - deviceProxyHome := config.GetDeviceProxyHome() - binaryName := "gbox-device-proxy" - if runtime.GOOS == "windows" { - binaryName += ".exe" - } - binaryPath := filepath.Join(deviceProxyHome, binaryName) - - // Check if binary exists - if _, err := os.Stat(binaryPath); err != nil { - // Binary doesn't exist, download it - return DownloadDeviceProxy() - } - - // Load cached version info - cachedInfo, err := loadVersionInfo() - if err != nil { - // No cache, download latest - return DownloadDeviceProxy() - } - - // Try to find release matching current version first - currentVersion := version.ClientInfo()["Version"] - currentCommit := version.ClientInfo()["GitCommit"] - - // First try to find exact version match - if currentVersion != "dev" { - release, err := getReleaseByTag(deviceProxyPublicRepo, currentVersion) - if err == nil { - assetURL, assetName, err := findDeviceProxyAssetForPlatform(release) - if err == nil { - // Found matching version, check if we need to download - if cachedInfo.TagName == currentVersion { - // Same version, return existing binary - return binaryPath, nil - } - // Different version, download - binaryPath, err := downloadAndExtractBinaryWithRetry(assetURL, assetName) - if err == nil { - // Save version info - saveVersionInfo(&VersionInfo{ - TagName: currentVersion, - CommitID: currentCommit, - Downloaded: time.Now().Format(time.RFC3339), - }) - return binaryPath, nil - } - } - } - } - - // If no exact match or failed, check if cached version is still valid - // If we have a cached version that exists as a release, respect it - if cachedInfo.TagName != "" { - // Verify the cached version exists as a release - _, err := getReleaseByTag(deviceProxyPublicRepo, cachedInfo.TagName) - if err == nil { - // Cached version exists as a release, use it - return binaryPath, nil - } - // If cached version doesn't exist as a release, fall through to download latest - } - - // For "dev" version or when no valid cached version, try latest release - if currentVersion == "dev" || cachedInfo.TagName == "" { - // Try latest release - release, err := getLatestRelease(deviceProxyPublicRepo) - if err != nil { - return "", fmt.Errorf("failed to get latest release: %v", err) - } - - // Check if we already have this version - if cachedInfo.TagName == release.TagName { - return binaryPath, nil - } - - // Download latest version - assetURL, assetName, err := findDeviceProxyAssetForPlatform(release) - if err != nil { - return "", fmt.Errorf("failed to find device proxy asset: %v", err) - } - - binaryPath, err = downloadAndExtractBinaryWithRetry(assetURL, assetName) - if err != nil { - return "", fmt.Errorf("failed to download device proxy: %v", err) - } - - // Save version info - saveVersionInfo(&VersionInfo{ - TagName: release.TagName, - CommitID: currentCommit, - Downloaded: time.Now().Format(time.RFC3339), - }) - - return binaryPath, nil - } - - // Fallback: try latest release - release, err := getLatestRelease(deviceProxyPublicRepo) - if err != nil { - return "", fmt.Errorf("failed to get latest release: %v", err) - } - - // Download latest version - assetURL, assetName, err := findDeviceProxyAssetForPlatform(release) - if err != nil { - return "", fmt.Errorf("failed to find device proxy asset: %v", err) - } - - binaryPath, err = downloadAndExtractBinaryWithRetry(assetURL, assetName) - if err != nil { - return "", fmt.Errorf("failed to download device proxy: %v", err) - } - - // Save version info - saveVersionInfo(&VersionInfo{ - TagName: release.TagName, - CommitID: currentCommit, - Downloaded: time.Now().Format(time.RFC3339), - }) - - return binaryPath, nil -} - -// DownloadDeviceProxy downloads the gbox-device-proxy binary from GitHub -// It first tries to download from a release matching the current version, -// and falls back to the latest release if no matching version is found -func DownloadDeviceProxy() (string, error) { - currentVersion := version.ClientInfo()["Version"] - currentCommit := version.ClientInfo()["GitCommit"] - - var release *GitHubRelease - var err error - - // First try to find release matching current version - if currentVersion != "dev" { - release, err = getReleaseByTag(deviceProxyPublicRepo, currentVersion) - if err == nil { - // Found matching version, try to download from it - assetURL, assetName, err := findDeviceProxyAssetForPlatform(release) - if err == nil { - binaryPath, err := downloadAndExtractBinaryWithRetry(assetURL, assetName) - if err == nil { - // Save version info for matching version - saveVersionInfo(&VersionInfo{ - TagName: release.TagName, - CommitID: currentCommit, - Downloaded: time.Now().Format(time.RFC3339), - }) - return binaryPath, nil - } - // If download failed, continue to try latest release - } - } - // If no matching version found or download failed, continue to latest release - } - - // Fallback to latest release - release, err = getLatestRelease(deviceProxyPublicRepo) - if err != nil { - return "", fmt.Errorf("failed to get latest release: %v", err) - } - - assetURL, assetName, err := findDeviceProxyAssetForPlatform(release) - if err != nil { - return "", fmt.Errorf("failed to find device proxy asset: %v", err) - } - - binaryPath, err := downloadAndExtractBinaryWithRetry(assetURL, assetName) - if err != nil { - return "", fmt.Errorf("failed to download device proxy: %v", err) - } - - // Save version info for latest release - saveVersionInfo(&VersionInfo{ - TagName: release.TagName, - CommitID: currentCommit, - Downloaded: time.Now().Format(time.RFC3339), - }) - - return binaryPath, nil -} - -// getLatestRelease fetches the latest release from GitHub -func getLatestRelease(repo string) (*GitHubRelease, error) { - url := fmt.Sprintf("%s/repos/%s/releases/latest", githubAPIURL, repo) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Accept", "application/vnd.github.v3+json") - req.Header.Set("User-Agent", "gbox-cli") - - // Add GitHub token if available - if token := os.Getenv("GITHUB_TOKEN"); token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // Read response body for better error message - body, _ := io.ReadAll(resp.Body) - errorMsg := fmt.Sprintf("GitHub API returned status: %d", resp.StatusCode) - if len(body) > 0 { - errorMsg += fmt.Sprintf(" - %s", string(body)) - } - - // Provide helpful suggestions for 403 errors - if resp.StatusCode == 403 { - errorMsg += "\n\nPossible solutions:\n1. Set GITHUB_TOKEN environment variable to avoid rate limits\n2. Check your network connection\n3. Retry later (GitHub API may have temporary restrictions)" - } - - return nil, fmt.Errorf("%s", errorMsg) - } - - var release GitHubRelease - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return nil, err - } - - return &release, nil -} - -// getReleaseByTag fetches a specific release by tag from GitHub -func getReleaseByTag(repo, tag string) (*GitHubRelease, error) { - url := fmt.Sprintf("%s/repos/%s/releases/tags/%s", githubAPIURL, repo, tag) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Accept", "application/vnd.github.v3+json") - req.Header.Set("User-Agent", "gbox-cli") - - // Add GitHub token if available - if token := os.Getenv("GITHUB_TOKEN"); token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // Read response body for better error message - body, _ := io.ReadAll(resp.Body) - errorMsg := fmt.Sprintf("GitHub API returned status: %d", resp.StatusCode) - if len(body) > 0 { - errorMsg += fmt.Sprintf(" - %s", string(body)) - } - - // Provide helpful suggestions for 403 errors - if resp.StatusCode == 403 { - errorMsg += "\n\nPossible solutions:\n1. Set GITHUB_TOKEN environment variable to avoid rate limits\n2. Check your network connection\n3. Retry later (GitHub API may have temporary restrictions)" - } - - return nil, fmt.Errorf("%s", errorMsg) - } - - var release GitHubRelease - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return nil, err - } - - return &release, nil -} - -// findDeviceProxyAssetForPlatform finds the device-proxy asset for the current platform -func findDeviceProxyAssetForPlatform(release *GitHubRelease) (string, string, error) { - osName := runtime.GOOS - arch := runtime.GOARCH - - // Map runtime.GOOS to asset name format - var platform string - switch osName { - case "darwin": - if arch == "amd64" { - platform = "darwin-amd64" - } else if arch == "arm64" { - platform = "darwin-arm64" - } - case "linux": - if arch == "amd64" { - platform = "linux-amd64" - } else if arch == "arm64" { - platform = "linux-arm64" - } - case "windows": - if arch == "amd64" { - platform = "windows-amd64" - } else if arch == "arm64" { - platform = "windows-arm64" - } - } - - if platform == "" { - return "", "", fmt.Errorf("unsupported platform: %s-%s", osName, arch) - } - - // Find device-proxy asset containing the platform - for _, asset := range release.Assets { - if strings.Contains(asset.Name, "gbox-device-proxy") && strings.Contains(asset.Name, platform) { - // Use browser_download_url for public access - if asset.DownloadURL != "" { - return asset.DownloadURL, asset.Name, nil - } - // fallback to API URL (may rate-limit/fail) - if asset.URL != "" { - return asset.URL, asset.Name, nil - } - } - } - - return "", "", fmt.Errorf("no device-proxy asset found for platform: %s", platform) -} - -// downloadAndExtractBinary downloads and extracts the binary file -func downloadAndExtractBinary(assetURL, assetName string) (string, error) { - // Get device proxy home directory first - deviceProxyHome := config.GetDeviceProxyHome() - if err := os.MkdirAll(deviceProxyHome, 0755); err != nil { - return "", err - } - - // Download the asset directly to device proxy home directory - assetPath := filepath.Join(deviceProxyHome, assetName) - if err := downloadFile(assetURL, assetPath); err != nil { - return "", err - } - - // Create temporary directory for extraction - tempDir, err := os.MkdirTemp("", "gbox-device-proxy-*") - if err != nil { - return "", err - } - defer os.RemoveAll(tempDir) - - // Extract the binary - binaryPath, err := extractBinary(assetPath, tempDir) - if err != nil { - return "", err - } - - binaryName := "gbox-device-proxy" - if runtime.GOOS == "windows" { - binaryName += ".exe" - } - - finalPath := filepath.Join(deviceProxyHome, binaryName) - - // Remove existing file if it exists (in case it's corrupted) - if _, err := os.Stat(finalPath); err == nil { - if err := os.Remove(finalPath); err != nil { - // Don't fail if we can't remove the file (it might be in use) - // Just log a warning and continue - fmt.Fprintf(os.Stderr, "Warning: Could not remove existing binary %s: %v\n", finalPath, err) - } - } - - if err := os.Rename(binaryPath, finalPath); err != nil { - // If rename fails, try copy and remove - if copyErr := copyFile(binaryPath, finalPath); copyErr != nil { - return "", fmt.Errorf("failed to move binary to final location: %v (copy failed: %v)", err, copyErr) - } - // Try to remove the original, but don't fail if it doesn't work - os.Remove(binaryPath) - } - - // Make binary executable - if err := os.Chmod(finalPath, 0755); err != nil { - return "", err - } - - return finalPath, nil -} - -// downloadAndExtractBinaryWithRetry downloads and extracts the binary file with retry logic -func downloadAndExtractBinaryWithRetry(assetURL, assetName string) (string, error) { - var binaryPath string - var lastErr error - maxRetries := 3 - for i := 0; i < maxRetries; i++ { - binaryPath, lastErr = downloadAndExtractBinary(assetURL, assetName) - if lastErr == nil { - break - } - - if i < maxRetries-1 { - fmt.Fprintf(os.Stderr, "Download attempt %d failed: %v. Retrying...\n", i+1, lastErr) - time.Sleep(time.Duration(i+1) * time.Second) // Exponential backoff - } - } - - if lastErr != nil { - return "", fmt.Errorf("failed to download and extract binary after %d attempts: %v", maxRetries, lastErr) - } - - return binaryPath, nil -} - -// downloadFile downloads a file from URL to local path -func downloadFile(url, filepath string) error { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return err - } - - req.Header.Set("Accept", "application/octet-stream") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - - client := &http.Client{ - Timeout: 30 * time.Second, - } - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download failed with status: %d", resp.StatusCode) - } - - file, err := os.Create(filepath) - if err != nil { - return err - } - defer file.Close() - - // Use a buffer to copy data and check for errors - buf := make([]byte, 32*1024) // 32KB buffer - for { - n, err := resp.Body.Read(buf) - if n > 0 { - if _, writeErr := file.Write(buf[:n]); writeErr != nil { - return fmt.Errorf("write error: %v", writeErr) - } - } - if err != nil { - if err == io.EOF { - break - } - return fmt.Errorf("read error: %v", err) - } - } - - return nil -} - -// copyFile copies a file from src to dst -func copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { - return err - } - defer sourceFile.Close() - - destFile, err := os.Create(dst) - if err != nil { - return err - } - defer destFile.Close() - - _, err = io.Copy(destFile, sourceFile) - return err -} - -// extractBinary extracts the binary from the downloaded asset -func extractBinary(assetPath, extractDir string) (string, error) { - if strings.HasSuffix(assetPath, ".tar.gz") { - return extractTarGz(assetPath, extractDir) - } else if strings.HasSuffix(assetPath, ".zip") { - return extractZip(assetPath, extractDir) - } - return "", fmt.Errorf("unsupported archive format: %s", assetPath) -} - -// extractTarGz extracts a .tar.gz file -func extractTarGz(archivePath, extractDir string) (string, error) { - file, err := os.Open(archivePath) - if err != nil { - return "", err - } - defer file.Close() - - gzr, err := gzip.NewReader(file) - if err != nil { - return "", err - } - defer gzr.Close() - - tr := tar.NewReader(gzr) - var binaryPath string - - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return "", err - } - - // Skip directories - if header.Typeflag == tar.TypeDir { - continue - } - - // Look for device-proxy binary - if strings.Contains(header.Name, "device-proxy") { - extractPath := filepath.Join(extractDir, filepath.Base(header.Name)) - extractFile, err := os.Create(extractPath) - if err != nil { - return "", err - } - - if _, err := io.Copy(extractFile, tr); err != nil { - extractFile.Close() - return "", err - } - extractFile.Close() - - binaryPath = extractPath - break - } - } - - if binaryPath == "" { - return "", fmt.Errorf("device-proxy binary not found in archive") - } - - return binaryPath, nil -} - -// extractZip extracts a .zip file -func extractZip(archivePath, extractDir string) (string, error) { - reader, err := zip.OpenReader(archivePath) - if err != nil { - return "", err - } - defer reader.Close() - - var binaryPath string - - for _, file := range reader.File { - // Look for device-proxy binary - if strings.Contains(file.Name, "device-proxy") { - extractPath := filepath.Join(extractDir, filepath.Base(file.Name)) - - // Create the file - extractFile, err := os.Create(extractPath) - if err != nil { - return "", err - } - - // Open the file in the archive - archiveFile, err := file.Open() - if err != nil { - extractFile.Close() - return "", err - } - - // Copy content - if _, err := io.Copy(extractFile, archiveFile); err != nil { - extractFile.Close() - archiveFile.Close() - return "", err - } - - extractFile.Close() - archiveFile.Close() - binaryPath = extractPath - break - } - } - - if binaryPath == "" { - return "", fmt.Errorf("device-proxy binary not found in archive") - } - - return binaryPath, nil -} diff --git a/packages/cli/internal/device_connect/downloader_test.go b/packages/cli/internal/device_connect/downloader_test.go deleted file mode 100644 index 3135a9ef..00000000 --- a/packages/cli/internal/device_connect/downloader_test.go +++ /dev/null @@ -1,341 +0,0 @@ -package device_connect - -import ( - "os" - "runtime" - "testing" - - "github.com/babelcloud/gbox/packages/cli/config" - "github.com/babelcloud/gbox/packages/cli/internal/version" -) - -func TestGetLatestRelease(t *testing.T) { - // Test getting latest release from public repository - release, err := getLatestRelease(deviceProxyPublicRepo) - if err != nil { - t.Fatalf("Failed to get latest release: %v", err) - } - - if release.TagName == "" { - t.Error("Expected release to have a tag name") - } - - if len(release.Assets) == 0 { - t.Error("Expected release to have assets") - } - - t.Logf("Latest release: %s with %d assets", release.TagName, len(release.Assets)) -} - -func TestFindDeviceProxyAssetForPlatform(t *testing.T) { - // First get a release - release, err := getLatestRelease(deviceProxyPublicRepo) - if err != nil { - t.Fatalf("Failed to get latest release: %v", err) - } - - // Test finding asset for current platform - _, assetName, err := findDeviceProxyAssetForPlatform(release) - if err != nil { - t.Fatalf("Failed to find device proxy asset: %v", err) - } - - if assetName == "" { - t.Error("Expected asset name to be non-empty") - } - - // Verify the asset name contains the expected platform - expectedPlatform := getExpectedPlatform() - if !contains(assetName, expectedPlatform) { - t.Errorf("Asset name '%s' should contain platform '%s'", assetName, expectedPlatform) - } - - t.Logf("Found asset: %s", assetName) -} - -func TestDownloadDeviceProxyIntegration(t *testing.T) { - // This is an integration test that actually downloads the binary - // Skip in CI environments to avoid rate limiting - if os.Getenv("CI") == "true" { - t.Skip("Skipping integration test in CI environment") - } - - // Test the full download process - binaryPath, err := DownloadDeviceProxy() - if err != nil { - t.Fatalf("Failed to download device proxy: %v", err) - } - - // Verify the binary exists and is executable - info, err := os.Stat(binaryPath) - if err != nil { - t.Fatalf("Failed to stat downloaded binary: %v", err) - } - - if info.IsDir() { - t.Error("Downloaded binary should not be a directory") - } - - // Check if it's executable (on Unix systems) - if runtime.GOOS != "windows" { - mode := info.Mode() - if mode&0111 == 0 { - t.Error("Downloaded binary should be executable") - } - } - - // Verify version cache was created - cachePath := getVersionCachePath() - if _, err := os.Stat(cachePath); err != nil { - t.Errorf("Version cache file should exist: %v", err) - } - - // Load and verify version info - versionInfo, err := loadVersionInfo() - if err != nil { - t.Fatalf("Failed to load version info: %v", err) - } - - if versionInfo.TagName == "" { - t.Error("Version info should have a tag name") - } - - t.Logf("Successfully downloaded device proxy binary: %s (%d bytes)", binaryPath, info.Size()) - t.Logf("Downloaded from version: %s", versionInfo.TagName) -} - -func TestCheckAndDownloadDeviceProxy(t *testing.T) { - // This test verifies the version-aware download functionality - // Skip in CI environments to avoid rate limiting - if os.Getenv("CI") == "true" { - t.Skip("Skipping integration test in CI environment") - } - - // Test the version-aware download process - binaryPath, err := CheckAndDownloadDeviceProxy() - if err != nil { - t.Fatalf("Failed to check and download device proxy: %v", err) - } - - // Verify the binary exists - info, err := os.Stat(binaryPath) - if err != nil { - t.Fatalf("Failed to stat downloaded binary: %v", err) - } - - if info.IsDir() { - t.Error("Downloaded binary should not be a directory") - } - - // Verify version cache was created - cachePath := getVersionCachePath() - if _, err := os.Stat(cachePath); err != nil { - t.Errorf("Version cache file should exist: %v", err) - } - - // Load and verify version info - versionInfo, err := loadVersionInfo() - if err != nil { - t.Fatalf("Failed to load version info: %v", err) - } - - if versionInfo.TagName == "" { - t.Error("Version info should have a tag name") - } - - if versionInfo.Downloaded == "" { - t.Error("Version info should have a download timestamp") - } - - t.Logf("Successfully checked and downloaded device proxy binary: %s", binaryPath) - t.Logf("Version info: %+v", versionInfo) -} - -func TestVersionCache(t *testing.T) { - // Test version cache functionality - tempDir, err := os.MkdirTemp("", "gbox-test-*") - if err != nil { - t.Fatalf("Failed to create temp directory: %v", err) - } - defer os.RemoveAll(tempDir) - - // Temporarily override device proxy home for testing - originalHome := config.GetDeviceProxyHome() - defer func() { - // Restore original home - os.Setenv("DEVICE_PROXY_HOME", originalHome) - }() - - os.Setenv("DEVICE_PROXY_HOME", tempDir) - - // Test saving and loading version info - testInfo := &VersionInfo{ - TagName: "v1.0.0", - CommitID: "abc123", - Downloaded: "2023-01-01T00:00:00Z", - } - - err = saveVersionInfo(testInfo) - if err != nil { - t.Fatalf("Failed to save version info: %v", err) - } - - loadedInfo, err := loadVersionInfo() - if err != nil { - t.Fatalf("Failed to load version info: %v", err) - } - - if loadedInfo.TagName != testInfo.TagName { - t.Errorf("Expected tag name %s, got %s", testInfo.TagName, loadedInfo.TagName) - } - - if loadedInfo.CommitID != testInfo.CommitID { - t.Errorf("Expected commit ID %s, got %s", testInfo.CommitID, loadedInfo.CommitID) - } - - if loadedInfo.Downloaded != testInfo.Downloaded { - t.Errorf("Expected downloaded time %s, got %s", testInfo.Downloaded, loadedInfo.Downloaded) - } - - t.Logf("Version cache test passed") -} - -func TestGetReleaseByTag(t *testing.T) { - // Test getting a specific release by tag - // Use a known tag that exists - release, err := getReleaseByTag(deviceProxyPublicRepo, "v0.1.7") - if err != nil { - t.Fatalf("Failed to get release by tag: %v", err) - } - - if release.TagName != "v0.1.7" { - t.Errorf("Expected tag name v0.1.7, got %s", release.TagName) - } - - if len(release.Assets) == 0 { - t.Error("Expected release to have assets") - } - - t.Logf("Successfully got release by tag: %s with %d assets", release.TagName, len(release.Assets)) -} - -func TestDownloadDeviceProxyVersionMatching(t *testing.T) { - // This test verifies that DownloadDeviceProxy tries to match current version first - // Skip in CI environments to avoid rate limiting - if os.Getenv("CI") == "true" { - t.Skip("Skipping integration test in CI environment") - } - - // Get current version info - clientInfo := version.ClientInfo() - currentVersion := clientInfo["Version"] - - t.Logf("Current CLI version: %s", currentVersion) - - // Test the download process - binaryPath, err := DownloadDeviceProxy() - if err != nil { - t.Fatalf("Failed to download device proxy: %v", err) - } - - // Verify the binary exists - info, err := os.Stat(binaryPath) - if err != nil { - t.Fatalf("Failed to stat downloaded binary: %v", err) - } - - if info.IsDir() { - t.Error("Downloaded binary should not be a directory") - } - - // Load version info to see which version was actually downloaded - versionInfo, err := loadVersionInfo() - if err != nil { - t.Fatalf("Failed to load version info: %v", err) - } - - t.Logf("Downloaded binary from version: %s", versionInfo.TagName) - t.Logf("Binary size: %d bytes", info.Size()) - - // If current version is not "dev", we should try to match it - if currentVersion != "dev" { - t.Logf("Current version is %s, checking if we tried to match it", currentVersion) - // Note: We can't easily verify which version was attempted first in this test - // but we can verify that the download succeeded and version info was saved - } - - t.Logf("Download completed successfully") -} - -// Helper functions - -func getExpectedPlatform() string { - osName := runtime.GOOS - arch := runtime.GOARCH - - switch osName { - case "darwin": - if arch == "amd64" { - return "darwin-amd64" - } else if arch == "arm64" { - return "darwin-arm64" - } - case "linux": - if arch == "amd64" { - return "linux-amd64" - } else if arch == "arm64" { - return "linux-arm64" - } - case "windows": - if arch == "amd64" { - return "windows-amd64" - } else if arch == "arm64" { - return "windows-arm64" - } - } - - return "" -} - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || - (len(s) > len(substr) && - (s[:len(substr)] == substr || - s[len(s)-len(substr):] == substr || - containsSubstring(s, substr)))) -} - -func containsSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - -// Benchmark tests - -func BenchmarkGetLatestRelease(b *testing.B) { - for i := 0; i < b.N; i++ { - _, err := getLatestRelease(deviceProxyPublicRepo) - if err != nil { - b.Fatalf("Failed to get latest release: %v", err) - } - } -} - -func BenchmarkFindDeviceProxyAssetForPlatform(b *testing.B) { - release, err := getLatestRelease(deviceProxyPublicRepo) - if err != nil { - b.Fatalf("Failed to get latest release: %v", err) - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _, err := findDeviceProxyAssetForPlatform(release) - if err != nil { - b.Fatalf("Failed to find device proxy asset: %v", err) - } - } -} diff --git a/packages/cli/internal/device_connect/kill.go b/packages/cli/internal/device_connect/kill.go deleted file mode 100644 index a2ede015..00000000 --- a/packages/cli/internal/device_connect/kill.go +++ /dev/null @@ -1,197 +0,0 @@ -package device_connect - -import ( - "fmt" - "os/exec" - "runtime" - "strings" -) - -// FindProcessesOnPort finds processes using a specific port -func FindProcessesOnPort(port int) ([]int, error) { - var cmd *exec.Cmd - var output []byte - var err error - - switch runtime.GOOS { - case "darwin", "linux": - // Use lsof to find processes using the port - cmd = exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port)) - output, err = cmd.Output() - if err != nil { - // If lsof fails, try to find gbox-device-proxy processes by name - return FindGboxDeviceProxyProcesses() - } - return parseLsofOutput(string(output)) - case "windows": - // Use netstat on Windows - cmd = exec.Command("netstat", "-ano") - output, err = cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to find processes on port %d: %v", port, err) - } - return parseWindowsNetstatOutput(string(output), port) - default: - return nil, fmt.Errorf("unsupported operating system: %s", runtime.GOOS) - } -} - -func parseLsofOutput(output string) ([]int, error) { - if output == "" { - return []int{}, nil - } - - lines := strings.Split(strings.TrimSpace(output), "\n") - var pids []int - - for _, line := range lines { - if line == "" { - continue - } - var pid int - if _, err := fmt.Sscanf(line, "%d", &pid); err == nil { - pids = append(pids, pid) - } - } - - return pids, nil -} - -func parseNetstatOutput(output string, port int) ([]int, error) { - lines := strings.Split(output, "\n") - var pids []int - - for _, line := range lines { - if strings.Contains(line, fmt.Sprintf(":%d", port)) { - // Extract PID from the last field - fields := strings.Fields(line) - if len(fields) > 0 { - lastField := fields[len(fields)-1] - if strings.Contains(lastField, "/") { - parts := strings.Split(lastField, "/") - if len(parts) > 0 { - var pid int - if _, err := fmt.Sscanf(parts[0], "%d", &pid); err == nil { - pids = append(pids, pid) - } - } - } - } - } - } - - return pids, nil -} - -func parseWindowsNetstatOutput(output string, port int) ([]int, error) { - lines := strings.Split(output, "\n") - var pids []int - - for _, line := range lines { - if strings.Contains(line, fmt.Sprintf(":%d", port)) { - // Extract PID from the last field - fields := strings.Fields(line) - if len(fields) > 0 { - lastField := fields[len(fields)-1] - var pid int - if _, err := fmt.Sscanf(lastField, "%d", &pid); err == nil { - pids = append(pids, pid) - } - } - } - } - - return pids, nil -} - -func FindGboxDeviceProxyProcesses() ([]int, error) { - // Use ps to find gbox-device-proxy processes - cmd := exec.Command("ps", "-ef") - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to find gbox-device-proxy processes: %v", err) - } - - lines := strings.Split(string(output), "\n") - var pids []int - - for _, line := range lines { - if strings.Contains(line, "gbox-device-proxy") && !strings.Contains(line, "grep") { - fields := strings.Fields(line) - if len(fields) > 1 { - var pid int - if _, err := fmt.Sscanf(fields[1], "%d", &pid); err == nil { - pids = append(pids, pid) - } - } - } - } - - return pids, nil -} - -// KillProcess kills a process by PID -func KillProcess(pid int, force bool) error { - var cmd *exec.Cmd - - if force { - switch runtime.GOOS { - case "darwin", "linux": - cmd = exec.Command("kill", "-9", fmt.Sprintf("%d", pid)) - case "windows": - cmd = exec.Command("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid)) - default: - return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) - } - } else { - switch runtime.GOOS { - case "darwin", "linux": - cmd = exec.Command("kill", fmt.Sprintf("%d", pid)) - case "windows": - cmd = exec.Command("taskkill", "/PID", fmt.Sprintf("%d", pid)) - default: - return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) - } - } - - return cmd.Run() -} - -// GetProcessCommand returns the command and arguments for a given process ID -func GetProcessCommand(pid int) (string, error) { - var cmd *exec.Cmd - - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "command=") - case "linux": - cmd = exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "args=") - case "windows": - cmd = exec.Command("wmic", "process", "where", fmt.Sprintf("ProcessId=%d", pid), "get", "CommandLine", "/value") - default: - return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) - } - - output, err := cmd.Output() - if err != nil { - return "", err - } - - command := strings.TrimSpace(string(output)) - if command == "" { - return "", fmt.Errorf("no command found for PID %d", pid) - } - - return command, nil -} - -// IsDeviceProxyProcess checks if a process is a device-proxy process by examining its command -func IsDeviceProxyProcess(pid int) bool { - command, err := GetProcessCommand(pid) - if err != nil { - return false - } - - // Check if the command contains "gbox-device-proxy" - return strings.Contains(command, "gbox-device-proxy") -} diff --git a/packages/cli/internal/device_connect/server.go b/packages/cli/internal/device_connect/server.go deleted file mode 100644 index b90008a4..00000000 --- a/packages/cli/internal/device_connect/server.go +++ /dev/null @@ -1,57 +0,0 @@ -package device_connect - -import ( - "fmt" - "log" - - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/api" -) - -// Server is the main device connect server -type Server struct { - apiServer *api.Server - port int -} - -// NewServer creates a new device connect server -func NewServer(port int) *Server { - return &Server{ - port: port, - apiServer: api.NewServer(port), - } -} - -// Start starts the device connect server -func (s *Server) Start() error { - log.Printf("Starting device connect server on port %d", s.port) - - // Start the API server - if err := s.apiServer.Start(); err != nil { - return fmt.Errorf("failed to start API server: %w", err) - } - - log.Printf("Device connect server started successfully on http://localhost:%d", s.port) - return nil -} - -// Stop stops the device connect server -func (s *Server) Stop() error { - log.Println("Stopping device connect server...") - - if s.apiServer != nil { - if err := s.apiServer.Stop(); err != nil { - return fmt.Errorf("failed to stop API server: %w", err) - } - } - - log.Println("Device connect server stopped") - return nil -} - -// IsRunning returns whether the server is running -func (s *Server) IsRunning() bool { - if s.apiServer != nil { - return s.apiServer.IsRunning() - } - return false -} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/version_test.go b/packages/cli/internal/device_connect/version_test.go deleted file mode 100644 index fdd7d449..00000000 --- a/packages/cli/internal/device_connect/version_test.go +++ /dev/null @@ -1,391 +0,0 @@ -package device_connect - -import ( - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/babelcloud/gbox/packages/cli/config" -) - -func TestVersionMatchingLogic(t *testing.T) { - // Test the version matching logic with different scenarios - tempDir, err := os.MkdirTemp("", "gbox-test-*") - if err != nil { - t.Fatalf("Failed to create temp directory: %v", err) - } - defer os.RemoveAll(tempDir) - - // Temporarily override device proxy home for testing - originalHome := config.GetDeviceProxyHome() - defer func() { - os.Setenv("DEVICE_PROXY_HOME", originalHome) - }() - - os.Setenv("DEVICE_PROXY_HOME", tempDir) - - // Test scenario 1: No binary exists - should download latest - t.Run("NoBinaryExists", func(t *testing.T) { - // Ensure no binary exists - binaryName := "gbox-device-proxy" - if runtime.GOOS == "windows" { - binaryName += ".exe" - } - binaryPath := filepath.Join(tempDir, binaryName) - os.Remove(binaryPath) - os.Remove(getVersionCachePath()) - - // Should download latest - path, err := CheckAndDownloadDeviceProxy() - if err != nil { - t.Fatalf("Failed to download when no binary exists: %v", err) - } - - if path != binaryPath { - t.Errorf("Expected binary path %s, got %s", binaryPath, path) - } - - // Verify version cache was created - if _, err := os.Stat(getVersionCachePath()); err != nil { - t.Errorf("Version cache should exist: %v", err) - } - }) - - // Test scenario 2: Binary exists with matching version - should not download - t.Run("BinaryExistsWithMatchingVersion", func(t *testing.T) { - // Create a fake binary - binaryName := "gbox-device-proxy" - if runtime.GOOS == "windows" { - binaryName += ".exe" - } - binaryPath := filepath.Join(tempDir, binaryName) - if err := os.WriteFile(binaryPath, []byte("fake binary"), 0755); err != nil { - t.Fatalf("Failed to create fake binary: %v", err) - } - - // Create version cache with current latest version - latestRelease, err := getLatestRelease(deviceProxyPublicRepo) - if err != nil { - t.Fatalf("Failed to get latest release: %v", err) - } - - cacheInfo := &VersionInfo{ - TagName: latestRelease.TagName, - CommitID: "test-commit", - Downloaded: time.Now().Format(time.RFC3339), - } - if err := saveVersionInfo(cacheInfo); err != nil { - t.Fatalf("Failed to save version info: %v", err) - } - - // Should return existing binary without downloading - path, err := CheckAndDownloadDeviceProxy() - if err != nil { - t.Fatalf("Failed to check version: %v", err) - } - - if path != binaryPath { - t.Errorf("Expected binary path %s, got %s", binaryPath, path) - } - - // Verify the binary wasn't replaced (content should still be "fake binary") - content, err := os.ReadFile(binaryPath) - if err != nil { - t.Fatalf("Failed to read binary: %v", err) - } - - if string(content) != "fake binary" { - t.Error("Binary should not have been replaced") - } - }) - - // Test scenario 3: Binary exists with different version - should download - t.Run("BinaryExistsWithDifferentVersion", func(t *testing.T) { - // Create a fake binary - binaryName := "gbox-device-proxy" - if runtime.GOOS == "windows" { - binaryName += ".exe" - } - binaryPath := filepath.Join(tempDir, binaryName) - if err := os.WriteFile(binaryPath, []byte("old fake binary"), 0755); err != nil { - t.Fatalf("Failed to create fake binary: %v", err) - } - - // Create version cache with old version - cacheInfo := &VersionInfo{ - TagName: "v0.0.1", // Old version - CommitID: "old-commit", - Downloaded: time.Now().Add(-24 * time.Hour).Format(time.RFC3339), - } - if err := saveVersionInfo(cacheInfo); err != nil { - t.Fatalf("Failed to save version info: %v", err) - } - - // Should download new version - path, err := CheckAndDownloadDeviceProxy() - if err != nil { - t.Fatalf("Failed to check version: %v", err) - } - - if path != binaryPath { - t.Errorf("Expected binary path %s, got %s", binaryPath, path) - } - - // Verify the binary was replaced (should be a real binary now) - info, err := os.Stat(binaryPath) - if err != nil { - t.Fatalf("Failed to stat binary: %v", err) - } - - if info.Size() < 1000 { // Real binary should be much larger - t.Error("Binary should have been replaced with real binary") - } - - // Verify version cache was updated - newCacheInfo, err := loadVersionInfo() - if err != nil { - t.Fatalf("Failed to load version info: %v", err) - } - - if newCacheInfo.TagName == "v0.0.1" { - t.Error("Version cache should have been updated") - } - }) -} - -func TestVersionCacheOperations(t *testing.T) { - tempDir, err := os.MkdirTemp("", "gbox-test-*") - if err != nil { - t.Fatalf("Failed to create temp directory: %v", err) - } - defer os.RemoveAll(tempDir) - - // Temporarily override device proxy home for testing - originalHome := config.GetDeviceProxyHome() - defer func() { - os.Setenv("DEVICE_PROXY_HOME", originalHome) - }() - - os.Setenv("DEVICE_PROXY_HOME", tempDir) - - // Test saving version info - t.Run("SaveVersionInfo", func(t *testing.T) { - testInfo := &VersionInfo{ - TagName: "v1.2.3", - CommitID: "abc123def456", - Downloaded: "2023-12-01T10:30:00Z", - } - - err := saveVersionInfo(testInfo) - if err != nil { - t.Fatalf("Failed to save version info: %v", err) - } - - // Verify file was created - cachePath := getVersionCachePath() - if _, err := os.Stat(cachePath); err != nil { - t.Errorf("Version cache file should exist: %v", err) - } - - // Verify content - data, err := os.ReadFile(cachePath) - if err != nil { - t.Fatalf("Failed to read cache file: %v", err) - } - - if !contains(string(data), "v1.2.3") { - t.Error("Cache file should contain version v1.2.3") - } - - if !contains(string(data), "abc123def456") { - t.Error("Cache file should contain commit ID") - } - }) - - // Test loading version info - t.Run("LoadVersionInfo", func(t *testing.T) { - // First save some data - testInfo := &VersionInfo{ - TagName: "v2.0.0", - CommitID: "xyz789", - Downloaded: "2023-12-02T15:45:00Z", - } - - if err := saveVersionInfo(testInfo); err != nil { - t.Fatalf("Failed to save version info: %v", err) - } - - // Then load it - loadedInfo, err := loadVersionInfo() - if err != nil { - t.Fatalf("Failed to load version info: %v", err) - } - - if loadedInfo.TagName != testInfo.TagName { - t.Errorf("Expected tag name %s, got %s", testInfo.TagName, loadedInfo.TagName) - } - - if loadedInfo.CommitID != testInfo.CommitID { - t.Errorf("Expected commit ID %s, got %s", testInfo.CommitID, loadedInfo.CommitID) - } - - if loadedInfo.Downloaded != testInfo.Downloaded { - t.Errorf("Expected downloaded time %s, got %s", testInfo.Downloaded, loadedInfo.Downloaded) - } - }) - - // Test loading non-existent file - t.Run("LoadNonExistentFile", func(t *testing.T) { - // Remove cache file - os.Remove(getVersionCachePath()) - - // Try to load - _, err := loadVersionInfo() - if err == nil { - t.Error("Expected error when loading non-existent file") - } - }) -} - -func TestGetReleaseByTagErrorHandling(t *testing.T) { - // Test getting a non-existent tag - t.Run("NonExistentTag", func(t *testing.T) { - _, err := getReleaseByTag(deviceProxyPublicRepo, "v999.999.999") - if err == nil { - t.Error("Expected error when getting non-existent tag") - } - }) - - // Test getting a valid tag - t.Run("ValidTag", func(t *testing.T) { - release, err := getReleaseByTag(deviceProxyPublicRepo, "v0.1.7") - if err != nil { - t.Fatalf("Failed to get valid tag: %v", err) - } - - if release.TagName != "v0.1.7" { - t.Errorf("Expected tag name v0.1.7, got %s", release.TagName) - } - }) -} - -func TestVersionMatchingPriority(t *testing.T) { - // This test verifies the version matching priority logic - tempDir, err := os.MkdirTemp("", "gbox-test-*") - if err != nil { - t.Fatalf("Failed to create temp directory: %v", err) - } - defer os.RemoveAll(tempDir) - - // Temporarily override device proxy home for testing - originalHome := config.GetDeviceProxyHome() - defer func() { - os.Setenv("DEVICE_PROXY_HOME", originalHome) - }() - - os.Setenv("DEVICE_PROXY_HOME", tempDir) - - // Test scenario: Current version is "dev" - should download latest - t.Run("DevVersionDownloadsLatest", func(t *testing.T) { - // Remove any existing binary and cache - binaryName := "gbox-device-proxy" - if runtime.GOOS == "windows" { - binaryName += ".exe" - } - binaryPath := filepath.Join(tempDir, binaryName) - os.Remove(binaryPath) - os.Remove(getVersionCachePath()) - - // Download with "dev" version (current test environment) - binaryPath, err := DownloadDeviceProxy() - if err != nil { - // Check if it's a rate limit error and skip the test - if strings.Contains(err.Error(), "403") || strings.Contains(err.Error(), "rate limit") { - t.Skip("GitHub API rate limit reached, skipping download test") - } - t.Fatalf("Failed to download device proxy: %v", err) - } - - // Verify binary was downloaded - if _, err := os.Stat(binaryPath); err != nil { - t.Fatalf("Binary should exist: %v", err) - } - - // Verify version cache was created - versionInfo, err := loadVersionInfo() - if err != nil { - t.Fatalf("Failed to load version info: %v", err) - } - - // Should have downloaded from latest release - if versionInfo.TagName == "" { - t.Error("Version info should have a tag name") - } - - t.Logf("Downloaded from version: %s", versionInfo.TagName) - }) - - // Test scenario: Current version matches existing release - t.Run("MatchingVersionDownloadsFromRelease", func(t *testing.T) { - // Remove any existing binary and cache - binaryName := "gbox-device-proxy" - if runtime.GOOS == "windows" { - binaryName += ".exe" - } - binaryPath := filepath.Join(tempDir, binaryName) - os.Remove(binaryPath) - os.Remove(getVersionCachePath()) - - // Create a fake binary to simulate existing installation - if err := os.WriteFile(binaryPath, []byte("fake binary"), 0755); err != nil { - t.Fatalf("Failed to create fake binary: %v", err) - } - - // Get the actual latest release to use in cache - latestRelease, err := getLatestRelease(deviceProxyPublicRepo) - if err != nil { - t.Skipf("Failed to get latest release: %v", err) - } - - // Create version cache with latest version to simulate existing installation - // that matches the latest available version - cacheInfo := &VersionInfo{ - TagName: latestRelease.TagName, // Use actual latest version - CommitID: "test-commit", - Downloaded: time.Now().Format(time.RFC3339), - } - if err := saveVersionInfo(cacheInfo); err != nil { - t.Fatalf("Failed to save version info: %v", err) - } - - // Use CheckAndDownloadDeviceProxy which should respect the cached version - newBinaryPath, err := CheckAndDownloadDeviceProxy() - if err != nil { - // Check if it's a rate limit error and skip the test - if strings.Contains(err.Error(), "403") || strings.Contains(err.Error(), "rate limit") { - t.Skip("GitHub API rate limit reached, skipping download test") - } - t.Fatalf("Failed to check and download device proxy: %v", err) - } - - // Should return existing binary path - if newBinaryPath != binaryPath { - t.Errorf("Expected binary path %s, got %s", binaryPath, newBinaryPath) - } - - // Verify the binary wasn't replaced (content should still be "fake binary") - content, err := os.ReadFile(binaryPath) - if err != nil { - t.Fatalf("Failed to read binary: %v", err) - } - - if string(content) != "fake binary" { - t.Error("Binary should not have been replaced") - } - - t.Logf("Successfully used cached version without downloading") - }) -} diff --git a/packages/cli/internal/server/adb_expose.go b/packages/cli/internal/server/adb_expose.go index 2fccb9e3..77504fca 100644 --- a/packages/cli/internal/server/adb_expose.go +++ b/packages/cli/internal/server/adb_expose.go @@ -4,41 +4,41 @@ import ( "encoding/json" "fmt" "log" + "net" "net/http" - "os/exec" - "strconv" - "strings" "sync" + "time" + + adb_expose "github.com/babelcloud/gbox/packages/cli/internal/adb_expose" + "github.com/babelcloud/gbox/packages/cli/internal/profile" ) -// ADBExposeService manages ADB port forwarding +// ADBExposeService manages ADB port expose for remote boxes type ADBExposeService struct { - mu sync.RWMutex - forwards map[string]*PortForward // key: "device:localPort:remotePort" - running bool + mu sync.RWMutex + running bool } -// PortForward represents an active port forward -type PortForward struct { - DeviceSerial string `json:"device_serial"` - LocalPort int `json:"local_port"` - RemotePort int `json:"remote_port"` - Protocol string `json:"protocol"` // tcp or unix - Active bool `json:"active"` +// BoxPortForward represents an active port forward for a remote box +type BoxPortForward struct { + BoxID string `json:"box_id"` + LocalPorts []int `json:"local_ports"` + RemotePorts []int `json:"remote_ports"` + Status string `json:"status"` // "running", "stopped", "error" + StartedAt time.Time `json:"started_at"` + Error string `json:"error,omitempty"` } // NewADBExposeService creates a new ADB expose service func NewADBExposeService() *ADBExposeService { - return &ADBExposeService{ - forwards: make(map[string]*PortForward), - } + return &ADBExposeService{} } // Start starts the ADB expose service func (s *ADBExposeService) Start() error { s.mu.Lock() defer s.mu.Unlock() - + s.running = true log.Println("ADB Expose service started") return nil @@ -48,15 +48,7 @@ func (s *ADBExposeService) Start() error { func (s *ADBExposeService) Stop() error { s.mu.Lock() defer s.mu.Unlock() - - // Clear all forwards - for key, forward := range s.forwards { - if err := s.removeForward(forward); err != nil { - log.Printf("Failed to remove forward %s: %v", key, err) - } - } - - s.forwards = make(map[string]*PortForward) + s.running = false log.Println("ADB Expose service stopped") return nil @@ -74,99 +66,48 @@ func (s *ADBExposeService) IsRunning() bool { return s.running } -// AddForward adds a new port forward -func (s *ADBExposeService) AddForward(deviceSerial string, localPort, remotePort int, protocol string) error { - s.mu.Lock() - defer s.mu.Unlock() - +// StartBoxPortForward starts ADB port expose for a remote box +func (s *ADBExposeService) StartBoxPortForward(boxID string, localPorts, remotePorts []int) error { + s.mu.RLock() + defer s.mu.RUnlock() + if !s.running { return fmt.Errorf("service not running") } - - key := fmt.Sprintf("%s:%d:%d", deviceSerial, localPort, remotePort) - - // Check if already exists - if _, exists := s.forwards[key]; exists { - return fmt.Errorf("forward already exists") - } - - // Execute adb forward command - var cmd *exec.Cmd - if deviceSerial == "" || deviceSerial == "default" { - cmd = exec.Command("adb", "forward", - fmt.Sprintf("tcp:%d", localPort), - fmt.Sprintf("%s:%d", protocol, remotePort)) - } else { - cmd = exec.Command("adb", "-s", deviceSerial, "forward", - fmt.Sprintf("tcp:%d", localPort), - fmt.Sprintf("%s:%d", protocol, remotePort)) - } - - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to add forward: %w", err) - } - - forward := &PortForward{ - DeviceSerial: deviceSerial, - LocalPort: localPort, - RemotePort: remotePort, - Protocol: protocol, - Active: true, - } - - s.forwards[key] = forward - log.Printf("Added forward: %s", key) - - return nil -} -// RemoveForward removes a port forward -func (s *ADBExposeService) RemoveForward(deviceSerial string, localPort, remotePort int) error { - s.mu.Lock() - defer s.mu.Unlock() - - key := fmt.Sprintf("%s:%d:%d", deviceSerial, localPort, remotePort) - - forward, exists := s.forwards[key] - if !exists { - return fmt.Errorf("forward not found") - } - - if err := s.removeForward(forward); err != nil { - return err - } - - delete(s.forwards, key) - log.Printf("Removed forward: %s", key) - + // ADB expose is now handled by the main server's HTTP handlers + // This method is kept for compatibility but the actual work is done + // by the HTTP handlers that call the new ADB expose implementation + + log.Printf("ADB port expose request for box %s: local ports %v -> remote ports %v", boxID, localPorts, remotePorts) return nil } -// removeForward executes adb command to remove forward -func (s *ADBExposeService) removeForward(forward *PortForward) error { - var cmd *exec.Cmd - if forward.DeviceSerial == "" || forward.DeviceSerial == "default" { - cmd = exec.Command("adb", "forward", "--remove", - fmt.Sprintf("tcp:%d", forward.LocalPort)) - } else { - cmd = exec.Command("adb", "-s", forward.DeviceSerial, "forward", "--remove", - fmt.Sprintf("tcp:%d", forward.LocalPort)) +// StopBoxPortForward stops ADB port expose for a remote box +func (s *ADBExposeService) StopBoxPortForward(boxID string) error { + s.mu.RLock() + defer s.mu.RUnlock() + + if !s.running { + return fmt.Errorf("service not running") } - - return cmd.Run() + + log.Printf("ADB port expose stop request for box %s", boxID) + return nil } -// ListForwards returns all active forwards -func (s *ADBExposeService) ListForwards() []*PortForward { +// ListBoxPortForwards returns all active box port forwards +func (s *ADBExposeService) ListBoxPortForwards() ([]*BoxPortForward, error) { s.mu.RLock() defer s.mu.RUnlock() - - forwards := make([]*PortForward, 0, len(s.forwards)) - for _, forward := range s.forwards { - forwards = append(forwards, forward) + + if !s.running { + return nil, fmt.Errorf("service not running") } - - return forwards + + // ADB expose is now handled by the main server's HTTP handlers + // Return empty list for compatibility + return make([]*BoxPortForward, 0), nil } // HTTP Handlers for ADB Expose @@ -176,47 +117,126 @@ func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - + var req struct { - DeviceSerial string `json:"device_serial"` - LocalPort int `json:"local_port"` - RemotePort int `json:"remote_port"` - Protocol string `json:"protocol"` + BoxID string `json:"box_id"` + LocalPorts []int `json:"local_ports"` + RemotePorts []int `json:"remote_ports"` } - + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid request body", }) return } - - // Default protocol to tcp - if req.Protocol == "" { - req.Protocol = "tcp" + + // Validate request + if req.BoxID == "" { + respondJSON(w, http.StatusBadRequest, map[string]string{ + "error": "box_id is required", + }) + return } - - // Start service if not running - if !s.adbExpose.IsRunning() { - if err := s.adbExpose.Start(); err != nil { + + if len(req.LocalPorts) == 0 || len(req.RemotePorts) == 0 { + respondJSON(w, http.StatusBadRequest, map[string]string{ + "error": "local_ports and remote_ports are required", + }) + return + } + + if len(req.LocalPorts) != len(req.RemotePorts) { + respondJSON(w, http.StatusBadRequest, map[string]string{ + "error": "local_ports and remote_ports must have the same length", + }) + return + } + + // Create a request for port forwarding + adbReq := StartRequest{ + BoxID: req.BoxID, + LocalPorts: req.LocalPorts, + RemotePorts: req.RemotePorts, + } + + // Call the ADB expose server's start method + // We need to get the configuration first + pm := profile.NewProfileManager() + if err := pm.Load(); err != nil { + respondJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Failed to load profile manager: " + err.Error(), + }) + return + } + + // Get API key + apiKey, err := pm.GetCurrentAPIKey() + if err != nil { + // Try to use the first available profile + profiles := pm.GetProfiles() + if len(profiles) == 0 { respondJSON(w, http.StatusInternalServerError, map[string]string{ - "error": err.Error(), + "error": "No profiles available. Please run 'gbox profile add' to add a profile first", + }) + return + } + + var firstProfileID string + for id := range profiles { + firstProfileID = id + break + } + + if err := pm.Use(firstProfileID); err != nil { + respondJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Failed to set profile: " + err.Error(), + }) + return + } + + apiKey, err = pm.GetCurrentAPIKey() + if err != nil { + respondJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Failed to get API key: " + err.Error(), }) return } } - - // Add forward - if err := s.adbExpose.AddForward(req.DeviceSerial, req.LocalPort, req.RemotePort, req.Protocol); err != nil { + + gboxURL := profile.Default.GetEffectiveBaseURL() + if gboxURL == "" { + respondJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "GBOX base URL not configured", + }) + return + } + + // Set up the configuration + adbReq.Config = adb_expose.Config{ + APIKey: apiKey, + BoxID: req.BoxID, + GboxURL: gboxURL, + LocalAddr: "127.0.0.1", + TargetPorts: req.RemotePorts, + } + + // Start port forwarding directly + log.Printf("Starting ADB port forward for box %s", req.BoxID) + forward, err := s.startPortForward(adbReq) + if err != nil { + log.Printf("Failed to start ADB port forward: %v", err) respondJSON(w, http.StatusInternalServerError, map[string]string{ - "error": err.Error(), + "error": "Failed to start ADB port expose: " + err.Error(), }) return } - + log.Printf("ADB port forward started successfully for box %s", req.BoxID) + respondJSON(w, http.StatusOK, map[string]interface{}{ "success": true, - "message": fmt.Sprintf("Port forward added: %d -> %d", req.LocalPort, req.RemotePort), + "message": fmt.Sprintf("ADB port exposed for box %s: %v -> %v", req.BoxID, req.LocalPorts, req.RemotePorts), + "data": forward, }) } @@ -225,103 +245,208 @@ func (s *GBoxServer) handleADBExposeStop(w http.ResponseWriter, r *http.Request) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - + var req struct { - DeviceSerial string `json:"device_serial"` - LocalPort int `json:"local_port"` - RemotePort int `json:"remote_port"` + BoxID string `json:"box_id"` } - + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // If no body, stop all forwards - if err := s.adbExpose.Stop(); err != nil { - respondJSON(w, http.StatusInternalServerError, map[string]string{ - "error": err.Error(), - }) - return - } - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "message": "All port forwards removed", + respondJSON(w, http.StatusBadRequest, map[string]string{ + "error": "box_id is required", + }) + return + } + + // Validate request + if req.BoxID == "" { + respondJSON(w, http.StatusBadRequest, map[string]string{ + "error": "box_id is required", }) return } - - // Remove specific forward - if err := s.adbExpose.RemoveForward(req.DeviceSerial, req.LocalPort, req.RemotePort); err != nil { + + // Stop port forwarding for the specific box + log.Printf("Stopping ADB port forward for box %s", req.BoxID) + if err := s.stopPortForward(req.BoxID); err != nil { + log.Printf("Failed to stop ADB port forward: %v", err) respondJSON(w, http.StatusInternalServerError, map[string]string{ - "error": err.Error(), + "error": "Failed to stop ADB port expose: " + err.Error(), }) return } - + log.Printf("ADB port forward stopped for box %s", req.BoxID) + respondJSON(w, http.StatusOK, map[string]interface{}{ "success": true, - "message": "Port forward removed", + "message": fmt.Sprintf("ADB port expose stopped for box %s", req.BoxID), }) } func (s *GBoxServer) handleADBExposeStatus(w http.ResponseWriter, r *http.Request) { status := map[string]interface{}{ - "running": s.adbExpose.IsRunning(), - "forwards": s.adbExpose.ListForwards(), + "running": s.adbExpose.IsRunning(), + } + + // Get box port forwards if service is running + if s.adbExpose.IsRunning() { + forwards, err := s.adbExpose.ListBoxPortForwards() + if err != nil { + status["error"] = err.Error() + } else { + status["forwards"] = forwards + } + } else { + status["forwards"] = []*BoxPortForward{} } - + respondJSON(w, http.StatusOK, status) } func (s *GBoxServer) handleADBExposeList(w http.ResponseWriter, r *http.Request) { - // Get all adb forwards from system - cmd := exec.Command("adb", "forward", "--list") - output, err := cmd.Output() + // Get port forwards directly from port manager + forwards := s.listPortForwards() + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "forwards": forwards, + "count": len(forwards), + }) +} + +// startPortForward starts port forwarding for a box +func (s *GBoxServer) startPortForward(req StartRequest) (*PortForward, error) { + // Get or create WebSocket connection + client, err := s.getOrCreateConnection(req.BoxID, req.Config) if err != nil { - respondJSON(w, http.StatusInternalServerError, map[string]string{ - "error": fmt.Sprintf("Failed to list forwards: %v", err), - }) - return + return nil, fmt.Errorf("failed to get connection: %v", err) } - - // Parse output - lines := strings.Split(string(output), "\n") - forwards := []map[string]interface{}{} - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue + + // Create port forward instance + forward := &PortForward{ + BoxID: req.BoxID, + LocalPorts: req.LocalPorts, + RemotePorts: req.RemotePorts, + StartedAt: time.Now(), + Status: "starting", + client: client, + } + + // Store the port forward in the manager + s.portManager.mu.Lock() + s.portManager.forwards[req.BoxID] = forward + s.portManager.mu.Unlock() + + // Start local listeners for each port + for i, localPort := range req.LocalPorts { + remotePort := req.RemotePorts[i] + go s.startLocalListener(forward, localPort, remotePort) + } + + forward.Status = "running" + return forward, nil +} + +// stopPortForward stops port forwarding for a box +func (s *GBoxServer) stopPortForward(boxID string) error { + s.portManager.mu.Lock() + defer s.portManager.mu.Unlock() + + forward, exists := s.portManager.forwards[boxID] + if !exists { + return fmt.Errorf("port forward not found for box %s", boxID) + } + + // Stop the port forward + forward.Stop() + + // Close the client connection if it exists + if forward.client != nil { + forward.client.Close() + } + + // Remove from manager + delete(s.portManager.forwards, boxID) + + return nil +} + +// listPortForwards returns all active port forwards +func (s *GBoxServer) listPortForwards() []*BoxPortForward { + s.portManager.mu.RLock() + defer s.portManager.mu.RUnlock() + + boxForwards := make([]*BoxPortForward, 0, len(s.portManager.forwards)) + for _, forward := range s.portManager.forwards { + boxForward := &BoxPortForward{ + BoxID: forward.BoxID, + LocalPorts: forward.LocalPorts, + RemotePorts: forward.RemotePorts, + Status: forward.Status, + StartedAt: forward.StartedAt, + Error: forward.Error, } - - // Format: serial tcp:local tcp:remote - parts := strings.Fields(line) - if len(parts) >= 3 { - localParts := strings.Split(parts[1], ":") - remoteParts := strings.Split(parts[2], ":") - - forward := map[string]interface{}{ - "device_serial": parts[0], - "local": parts[1], - "remote": parts[2], - } - - // Try to parse ports - if len(localParts) == 2 { - if port, err := strconv.Atoi(localParts[1]); err == nil { - forward["local_port"] = port - } - } - if len(remoteParts) == 2 { - if port, err := strconv.Atoi(remoteParts[1]); err == nil { - forward["remote_port"] = port - } + boxForwards = append(boxForwards, boxForward) + } + + return boxForwards +} + +// getOrCreateConnection gets or creates a WebSocket connection for a box +func (s *GBoxServer) getOrCreateConnection(boxID string, config adb_expose.Config) (*adb_expose.MultiplexClient, error) { + s.connectionPool.mu.Lock() + defer s.connectionPool.mu.Unlock() + + // Check if connection already exists + if client, exists := s.connectionPool.connections[boxID]; exists { + return client, nil + } + + // Create new connection + client, err := adb_expose.ConnectWebSocket(config) + if err != nil { + return nil, fmt.Errorf("failed to connect WebSocket: %v", err) + } + + // Start client handler + go func() { + if err := client.Run(); err != nil { + log.Printf("WebSocket connection closed for box %s: %v", boxID, err) + } + // Remove from connection pool on error + s.connectionPool.mu.Lock() + delete(s.connectionPool.connections, boxID) + s.connectionPool.mu.Unlock() + }() + + s.connectionPool.connections[boxID] = client + return client, nil +} + +// startLocalListener starts a local listener for port forwarding +func (s *GBoxServer) startLocalListener(forward *PortForward, localPort, remotePort int) { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", localPort)) + if err != nil { + forward.mu.Lock() + forward.Status = "error" + forward.Error = fmt.Sprintf("Failed to listen on port %d: %v", localPort, err) + forward.mu.Unlock() + return + } + defer listener.Close() + + log.Printf("Listening on port %d for box %s", localPort, forward.BoxID) + + for { + conn, err := listener.Accept() + if err != nil { + // Only log if it's not a normal shutdown + if forward.Status != "stopped" { + log.Printf("Failed to accept connection on port %d: %v", localPort, err) } - - forwards = append(forwards, forward) + continue } + + // Handle connection in goroutine + go adb_expose.HandleLocalConnWithClient(conn, forward.client, remotePort) } - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "forwards": forwards, - "managed": s.adbExpose.ListForwards(), - }) -} \ No newline at end of file +} diff --git a/packages/cli/internal/server/auto_start.go b/packages/cli/internal/server/auto_start.go new file mode 100644 index 00000000..24a50bc4 --- /dev/null +++ b/packages/cli/internal/server/auto_start.go @@ -0,0 +1,148 @@ +package server + +import ( + "fmt" + "log" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + "time" +) + +// AutoStartManager manages automatic server startup +type AutoStartManager struct { + serverPort int + pidFile string + logFile string +} + +// NewAutoStartManager creates a new auto-start manager +func NewAutoStartManager(serverPort int) *AutoStartManager { + homeDir, _ := os.UserHomeDir() + gboxDir := filepath.Join(homeDir, ".gbox", "cli") + + return &AutoStartManager{ + serverPort: serverPort, + pidFile: filepath.Join(gboxDir, "gbox-server.pid"), + logFile: filepath.Join(gboxDir, "server.log"), + } +} + +// EnsureServerRunning ensures the GBOX server is running, starting it if necessary +func (m *AutoStartManager) EnsureServerRunning() error { + // Check if server is already running + if m.isServerRunning() { + return nil + } + + // Start server in background + return m.startServerInBackground() +} + +// isServerRunning checks if the server is already running +func (m *AutoStartManager) isServerRunning() bool { + // Check if port is listening + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", m.serverPort), 1*time.Second) + if err != nil { + return false + } + conn.Close() + return true +} + +// startServerInBackground starts the server in background mode +func (m *AutoStartManager) startServerInBackground() error { + // Get the current executable path + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %v", err) + } + + // Create command to start server in background + cmd := exec.Command(execPath, "server", "start", "--daemon") + + // Set up process attributes for daemon mode + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, // Create new process group + } + + // Redirect output to log file + logFile, err := os.OpenFile(m.logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("failed to open log file: %v", err) + } + defer logFile.Close() + + cmd.Stdout = logFile + cmd.Stderr = logFile + + // Start the process + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start server: %v", err) + } + + // Write PID to file + pidFile, err := os.OpenFile(m.pidFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to create PID file: %v", err) + } + defer pidFile.Close() + + if _, err := pidFile.WriteString(strconv.Itoa(cmd.Process.Pid)); err != nil { + return fmt.Errorf("failed to write PID file: %v", err) + } + + // Wait a bit for server to start + time.Sleep(2 * time.Second) + + // Verify server is running + if !m.isServerRunning() { + return fmt.Errorf("server failed to start properly") + } + + log.Printf("GBOX server started in background (PID: %d)", cmd.Process.Pid) + return nil +} + +// StopServer stops the background server +func (m *AutoStartManager) StopServer() error { + // Read PID from file + pidBytes, err := os.ReadFile(m.pidFile) + if err != nil { + return fmt.Errorf("failed to read PID file: %v", err) + } + + pid, err := strconv.Atoi(string(pidBytes)) + if err != nil { + return fmt.Errorf("invalid PID in file: %v", err) + } + + // Send SIGTERM to the process + process, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("failed to find process: %v", err) + } + + if err := process.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("failed to send SIGTERM: %v", err) + } + + // Wait for process to exit + time.Sleep(1 * time.Second) + + // Remove PID file + if err := os.Remove(m.pidFile); err != nil { + log.Printf("Warning: failed to remove PID file: %v", err) + } + + log.Printf("GBOX server stopped (PID: %d)", pid) + return nil +} + +// IsServerRunning returns whether the server is running +func (m *AutoStartManager) IsServerRunning() bool { + return m.isServerRunning() +} diff --git a/packages/cli/internal/server/server.go b/packages/cli/internal/server/server.go index 3cf28a37..b8dbf027 100644 --- a/packages/cli/internal/server/server.go +++ b/packages/cli/internal/server/server.go @@ -5,6 +5,7 @@ import ( "embed" "encoding/json" "fmt" + "io" "io/fs" "log" "net/http" @@ -14,40 +15,97 @@ import ( "sync" "time" + adb_expose "github.com/babelcloud/gbox/packages/cli/internal/adb_expose" + client "github.com/babelcloud/gbox/packages/cli/internal/client" "github.com/babelcloud/gbox/packages/cli/internal/device_connect/webrtc" ) //go:embed all:static var staticFiles embed.FS +// PortManager manages all port forwarding instances +type PortManager struct { + forwards map[string]*PortForward // key: boxID + mu sync.RWMutex +} + +// PortForward represents a port forwarding instance +type PortForward struct { + BoxID string `json:"boxid"` + LocalPorts []int `json:"localports"` + RemotePorts []int `json:"remoteports"` + StartedAt time.Time `json:"started_at"` + Status string `json:"status"` // "running", "stopped", "error" + Error string `json:"error,omitempty"` + client *adb_expose.MultiplexClient + mu sync.RWMutex +} + +// ConnectionPool manages WebSocket connections to remote servers +type ConnectionPool struct { + connections map[string]*adb_expose.MultiplexClient // key: boxID + mu sync.RWMutex +} + +// StartRequest represents a request to start port forwarding +type StartRequest struct { + BoxID string `json:"boxid"` + LocalPorts []int `json:"localports"` + RemotePorts []int `json:"remoteports"` + Config adb_expose.Config `json:"config"` +} + +// Stop stops the port forward +func (pf *PortForward) Stop() { + pf.mu.Lock() + defer pf.mu.Unlock() + + if pf.Status == "stopped" { + return + } + + pf.Status = "stopped" + if pf.client != nil { + pf.client.Close() + } +} + // GBoxServer is the unified server for all gbox services type GBoxServer struct { - port int - httpServer *http.Server - mux *http.ServeMux - + port int + httpServer *http.Server + mux *http.ServeMux + // Services webrtcManager *webrtc.Manager adbExpose *ADBExposeService - + + // ADB Expose functionality integrated directly + portManager *PortManager + connectionPool *ConnectionPool + // State - mu sync.RWMutex - running bool - ctx context.Context - cancel context.CancelFunc + mu sync.RWMutex + running bool + startTime time.Time + buildID string // Store build ID at startup + ctx context.Context + cancel context.CancelFunc } // NewGBoxServer creates a new unified gbox server func NewGBoxServer(port int) *GBoxServer { ctx, cancel := context.WithCancel(context.Background()) - + return &GBoxServer{ - port: port, - mux: http.NewServeMux(), - webrtcManager: webrtc.NewManager("adb"), - adbExpose: NewADBExposeService(), - ctx: ctx, - cancel: cancel, + port: port, + mux: http.NewServeMux(), + webrtcManager: webrtc.NewManager("adb"), + adbExpose: NewADBExposeService(), + portManager: &PortManager{forwards: make(map[string]*PortForward)}, + connectionPool: &ConnectionPool{connections: make(map[string]*adb_expose.MultiplexClient)}, + ctx: ctx, + cancel: cancel, } } @@ -55,29 +113,35 @@ func NewGBoxServer(port int) *GBoxServer { func (s *GBoxServer) Start() error { s.mu.Lock() defer s.mu.Unlock() - + if s.running { return fmt.Errorf("server already running") } - + + // Set start time and build ID + s.startTime = time.Now() + s.buildID = GetBuildID() + + // ADB expose functionality is now integrated directly into this server + // Setup routes s.setupRoutes() - + s.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", s.port), Handler: s.mux, } - + // Start server in background go func() { - log.Printf("Starting GBox server on port %d", s.port) + // Starting GBox server if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Printf("HTTP server error: %v", err) } }() - + s.running = true - log.Printf("GBox server started successfully on http://localhost:%d", s.port) + // Server started successfully (no log needed here) return nil } @@ -85,13 +149,13 @@ func (s *GBoxServer) Start() error { func (s *GBoxServer) Stop() error { s.mu.Lock() defer s.mu.Unlock() - + if !s.running { return nil } - + s.cancel() - + // Shutdown HTTP server if s.httpServer != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -100,11 +164,26 @@ func (s *GBoxServer) Stop() error { log.Printf("HTTP server shutdown error: %v", err) } } - + // Cleanup services s.webrtcManager.Close() s.adbExpose.Close() - + + // Cleanup ADB expose functionality + s.portManager.mu.Lock() + for _, forward := range s.portManager.forwards { + forward.Stop() + } + s.portManager.forwards = make(map[string]*PortForward) + s.portManager.mu.Unlock() + + s.connectionPool.mu.Lock() + for _, client := range s.connectionPool.connections { + client.Close() + } + s.connectionPool.connections = make(map[string]*adb_expose.MultiplexClient) + s.connectionPool.mu.Unlock() + s.running = false log.Println("GBox server stopped") return nil @@ -119,57 +198,50 @@ func (s *GBoxServer) IsRunning() bool { // setupRoutes sets up all HTTP routes func (s *GBoxServer) setupRoutes() { - // Health check - s.mux.HandleFunc("/health", s.handleHealth) + // Health check and status + s.mux.HandleFunc("/health", s.handleStatus) s.mux.HandleFunc("/api/status", s.handleStatus) - + // Device Connect API (scrcpy/WebRTC) s.mux.HandleFunc("/api/devices", s.handleDevices) s.mux.HandleFunc("/api/devices/", s.handleDeviceAction) // Handles /api/devices/{id}/connect and /api/devices/{id}/disconnect s.mux.HandleFunc("/api/devices/register", s.handleRegisterDevice) s.mux.HandleFunc("/api/devices/unregister", s.handleUnregisterDevice) s.mux.HandleFunc("/ws", s.handleWebSocket) - + + // Box API + s.mux.HandleFunc("/api/boxes", s.handleBoxList) + // ADB Expose API s.mux.HandleFunc("/api/adb-expose/start", s.handleADBExposeStart) s.mux.HandleFunc("/api/adb-expose/stop", s.handleADBExposeStop) s.mux.HandleFunc("/api/adb-expose/status", s.handleADBExposeStatus) s.mux.HandleFunc("/api/adb-expose/list", s.handleADBExposeList) - + // Server management API s.mux.HandleFunc("/api/server/shutdown", s.handleShutdown) s.mux.HandleFunc("/api/server/info", s.handleServerInfo) - + + // Live-view assets - serve from live-view static directory + s.mux.HandleFunc("/assets/", s.handleLiveViewAssets) + // Sub-applications - handle both with and without trailing slash s.mux.HandleFunc("/live-view", s.handleLiveView) - s.mux.HandleFunc("/live-view/", s.handleLiveView) + s.mux.HandleFunc("/live-view/", s.handleLiveView) s.mux.HandleFunc("/live-view.html", s.handleLiveViewHTML) s.mux.HandleFunc("/adb-expose", s.handleAdbExposeUI) s.mux.HandleFunc("/adb-expose/", s.handleAdbExposeUI) - + // Static files and web UI routes - must be last s.setupStaticFiles() } // setupStaticFiles sets up static file serving func (s *GBoxServer) setupStaticFiles() { - // First, try to serve live-view static files if available - liveViewPath := s.findLiveViewStaticPath() - if liveViewPath != "" { - // Serve assets directory for CSS/JS files - assetsPath := filepath.Join(liveViewPath, "assets") - if _, err := os.Stat(assetsPath); err == nil { - s.mux.Handle("/assets/", http.StripPrefix("/assets/", s.serveStaticWithMIME(http.Dir(assetsPath)))) - } - // Also handle root static files - s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(liveViewPath)))) - } - - // Try embedded files + // Try embedded server static files staticFS, err := fs.Sub(staticFiles, "static") if err == nil { s.mux.Handle("/", http.FileServer(http.FS(staticFS))) - log.Println("Serving embedded static files") } else { // Fallback to a simple status page s.mux.HandleFunc("/", s.handleRoot) @@ -193,21 +265,21 @@ func (s *GBoxServer) serveStaticWithMIME(fs http.FileSystem) http.Handler { // findLiveViewStaticPath finds the live-view build output func (s *GBoxServer) findLiveViewStaticPath() string { - // Try various possible locations for the live-view static files + // Note: embedded files removed - using external files only + + // Fallback to external files for development possiblePaths := []string{ // Relative to gbox binary location "../../live-view/static", "../live-view/static", "packages/live-view/static", - // In gbox workspace - "/Users/duwan/Workspaces/babelcloud/gbox/packages/live-view/static", // In user's home directory filepath.Join(os.Getenv("HOME"), ".gbox", "live-view-static"), // Development paths "./packages/live-view/static", "../../../gbox/packages/live-view/static", } - + for _, path := range possiblePaths { absPath, err := filepath.Abs(path) if err != nil { @@ -215,34 +287,83 @@ func (s *GBoxServer) findLiveViewStaticPath() string { } if info, err := os.Stat(absPath); err == nil && info.IsDir() { if _, err := os.Stat(filepath.Join(absPath, "index.html")); err == nil { - log.Printf("Found live-view static files at: %s", absPath) return absPath } } } - + log.Printf("Warning: Live-view static files not found, using default status page") return "" } -// API Handlers +// findStaticPath finds the server static files directory +func (s *GBoxServer) findStaticPath() string { + // Try various possible locations for the server static files + possiblePaths := []string{ + // Relative to gbox binary location + "../../cli/internal/server/static", + "../cli/internal/server/static", + "packages/cli/internal/server/static", + // Development paths + "./packages/cli/internal/server/static", + "../../../gbox/packages/cli/internal/server/static", + // Current directory + "./static", + "static", + } -func (s *GBoxServer) handleHealth(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + for _, path := range possiblePaths { + absPath, err := filepath.Abs(path) + if err != nil { + continue + } + if info, err := os.Stat(absPath); err == nil && info.IsDir() { + return absPath + } + } + + log.Printf("Warning: Server static files not found") + return "" } +// getScrcpyServerPath returns the path to scrcpy-server.jar +func (s *GBoxServer) getScrcpyServerPath() string { + // Check external file + possiblePaths := []string{ + "assets/scrcpy-server.jar", + "../assets/scrcpy-server.jar", + "../../assets/scrcpy-server.jar", + "packages/cli/assets/scrcpy-server.jar", + } + + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + + return "" +} + +// API Handlers + func (s *GBoxServer) handleStatus(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + uptime := time.Since(s.startTime) + s.mu.RUnlock() + status := map[string]interface{}{ - "running": s.IsRunning(), - "port": s.port, + "running": s.IsRunning(), + "port": s.port, + "uptime": uptime.String(), "services": map[string]interface{}{ "device_connect": true, "adb_expose": s.adbExpose.IsRunning(), }, - "version": "1.0.0", + "version": BuildInfo.Version, + "build_id": GetBuildID(), } - + respondJSON(w, http.StatusOK, status) } @@ -252,127 +373,34 @@ func (s *GBoxServer) handleRoot(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - - html := ` - - - GBox Server - - - - -
- 🟢 Server Running -
- - - -` - - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, html) + + // Serve the index.html file from static directory + s.serveStaticFileSimple(w, r, "index.html") +} + +// serveStaticFileSimple serves a file from the embedded static files +func (s *GBoxServer) serveStaticFileSimple(w http.ResponseWriter, r *http.Request, filename string) { + // Read the file from embedded filesystem + file, err := staticFiles.Open("static/" + filename) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + + // Set appropriate content type + if strings.HasSuffix(filename, ".html") { + w.Header().Set("Content-Type", "text/html") + } else if strings.HasSuffix(filename, ".css") { + w.Header().Set("Content-Type", "text/css") + } else if strings.HasSuffix(filename, ".js") { + w.Header().Set("Content-Type", "application/javascript") + } else if strings.HasSuffix(filename, ".svg") { + w.Header().Set("Content-Type", "image/svg+xml") + } + + // Copy file content to response + io.Copy(w, file) } func (s *GBoxServer) handleShutdown(w http.ResponseWriter, r *http.Request) { @@ -380,11 +408,11 @@ func (s *GBoxServer) handleShutdown(w http.ResponseWriter, r *http.Request) { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - + respondJSON(w, http.StatusOK, map[string]string{ "message": "Server shutting down", }) - + // Shutdown after response go func() { time.Sleep(100 * time.Millisecond) @@ -394,22 +422,94 @@ func (s *GBoxServer) handleShutdown(w http.ResponseWriter, r *http.Request) { } func (s *GBoxServer) handleServerInfo(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + uptime := time.Since(s.startTime) + s.mu.RUnlock() + info := map[string]interface{}{ - "version": "1.0.0", - "port": s.port, - "uptime": time.Since(time.Now()).String(), // TODO: track actual start time + "version": BuildInfo.Version, + "build_id": s.buildID, // Use stored build ID from startup + "port": s.port, + "uptime": uptime.String(), "services": []string{ "device-connect", "adb-expose", }, } - + + // Set CORS headers for debugging + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + respondJSON(w, http.StatusOK, info) } +func (s *GBoxServer) handleBoxList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse query parameters + query := r.URL.Query() + typeFilter := query.Get("type") // e.g., ?type=android + + // Create GBOX client from profile + sdkClient, err := client.NewClientFromProfile() + if err != nil { + log.Printf("Failed to create GBOX client: %v", err) + respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "error": "Failed to initialize GBOX client", + }) + return + } + + // Call GBOX API to get real box list + boxesData, err := client.ListBoxesRawData(sdkClient, []string{}) + if err != nil { + log.Printf("Failed to list boxes from GBOX API: %v", err) + respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "error": "Failed to fetch boxes from GBOX API", + }) + return + } + + // Convert to the expected format and add name field + var allBoxes []map[string]interface{} + for _, box := range boxesData { + // Add name field if not present (use ID as fallback) + if _, ok := box["name"]; !ok { + if id, ok := box["id"].(string); ok { + box["name"] = id + } + } + allBoxes = append(allBoxes, box) + } + + // Filter boxes by type if specified + var filteredBoxes []map[string]interface{} + if typeFilter != "" { + for _, box := range allBoxes { + if boxType, ok := box["type"].(string); ok && boxType == typeFilter { + filteredBoxes = append(filteredBoxes, box) + } + } + } else { + filteredBoxes = allBoxes + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "boxes": filteredBoxes, + "filter": map[string]interface{}{ + "type": typeFilter, + }, + }) +} + // Helper function to send JSON responses func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) json.NewEncoder(w).Encode(data) -} \ No newline at end of file +} diff --git a/packages/cli/internal/server/static/adb-expose.html b/packages/cli/internal/server/static/adb-expose.html new file mode 100644 index 00000000..68476557 --- /dev/null +++ b/packages/cli/internal/server/static/adb-expose.html @@ -0,0 +1,418 @@ + + + + ADB Expose - GBOX Local Server + + + + + +
+

🔌 ADB Expose

+ ← Back to Home +
+ +
+
+

Add Box Port Forward

+
+
+ + +
+
+ + + Local port to bind to +
+
+ +
+ +
+

Active ADB Port Exposes

+
+ +
+
+
+ + + + diff --git a/packages/cli/internal/server/static/favicon.svg b/packages/cli/internal/server/static/favicon.svg new file mode 100644 index 00000000..7e318c64 --- /dev/null +++ b/packages/cli/internal/server/static/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/packages/cli/internal/server/static/index.html b/packages/cli/internal/server/static/index.html index f2cf0d05..05815a3e 100644 --- a/packages/cli/internal/server/static/index.html +++ b/packages/cli/internal/server/static/index.html @@ -3,7 +3,8 @@ - GBox Server + GBOX Local Server + -
- 🟢 Server Running +
+
+
+ Server Running +
+
+ Version: + Loading... +
+
+ Build ID: + Loading... +
+
+ Port: + Loading... +
+
+ Uptime: + Loading... +
-

GBox Server

-

Choose a service to continue

+

GBOX Local Server

+

Local service for GBOX CLI

@@ -114,5 +163,75 @@

ADB Expose

+ + \ No newline at end of file diff --git a/packages/cli/internal/server/static/live-view.html b/packages/cli/internal/server/static/live-view.html new file mode 100644 index 00000000..30f754da --- /dev/null +++ b/packages/cli/internal/server/static/live-view.html @@ -0,0 +1,71 @@ + + + + Live View - GBOX Local Server + + + + + +
+

📱 Live View

+
+

The Live View interface is not yet built.

+

To enable the full WebRTC streaming interface:

+ cd packages/live-view && pnpm install && pnpm build:static +

+ After building, restart the server to load the interface. +

+
+ ← Back to Home +
+ + diff --git a/packages/cli/internal/server/static_handlers.go b/packages/cli/internal/server/static_handlers.go new file mode 100644 index 00000000..a92be479 --- /dev/null +++ b/packages/cli/internal/server/static_handlers.go @@ -0,0 +1,255 @@ +package server + +import ( + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" +) + +// StaticFileConfig represents configuration for serving static files +type StaticFileConfig struct { + // BasePath is the base directory to serve files from + BasePath string + // FallbackFile is the file to serve if the requested file is not found + FallbackFile string + // ContentType is the MIME type to set (if empty, will be auto-detected) + ContentType string + // RedirectPath is the path to redirect to if no fallback is available + RedirectPath string +} + +// serveStaticFile serves a static file with the given configuration +func (s *GBoxServer) serveStaticFile(w http.ResponseWriter, r *http.Request, config StaticFileConfig) { + // Extract the requested file path from the URL + requestedPath := strings.TrimPrefix(r.URL.Path, "/") + if requestedPath == "" { + requestedPath = "index.html" + } + + // Try to serve the requested file + filePath := filepath.Join(config.BasePath, requestedPath) + if s.tryServeFile(w, filePath, config.ContentType) { + return + } + + // Try to serve fallback file + if config.FallbackFile != "" { + fallbackPath := filepath.Join(config.BasePath, config.FallbackFile) + if s.tryServeFile(w, fallbackPath, config.ContentType) { + return + } + } + + // Try to serve from server static directory as last resort + if config.BasePath != s.findStaticPath() { + serverStaticPath := s.findStaticPath() + if serverStaticPath != "" { + serverFilePath := filepath.Join(serverStaticPath, requestedPath) + if s.tryServeFile(w, serverFilePath, config.ContentType) { + return + } + + if config.FallbackFile != "" { + serverFallbackPath := filepath.Join(serverStaticPath, config.FallbackFile) + if s.tryServeFile(w, serverFallbackPath, config.ContentType) { + return + } + } + } + } + + // If redirect path is specified, redirect + if config.RedirectPath != "" { + http.Redirect(w, r, config.RedirectPath, http.StatusTemporaryRedirect) + return + } + + // Final fallback: simple error message + s.serveErrorPage(w, requestedPath) +} + +// tryServeFile attempts to serve a file and returns true if successful +func (s *GBoxServer) tryServeFile(w http.ResponseWriter, filePath string, contentType string) bool { + // Check if file exists + if _, err := os.Stat(filePath); err != nil { + return false + } + + // Open and serve the file + file, err := os.Open(filePath) + if err != nil { + return false + } + defer file.Close() + + // Set content type + if contentType == "" { + contentType = s.getContentType(filePath) + } + w.Header().Set("Content-Type", contentType) + + // Copy file content to response + _, err = io.Copy(w, file) + return err == nil +} + +// tryServeEmbeddedFile attempts to serve a file from embedded FS and returns true if successful +func (s *GBoxServer) tryServeEmbeddedFile(w http.ResponseWriter, fsys fs.FS, filePath string, contentType string) bool { + // Check if file exists in embedded FS + if _, err := fs.Stat(fsys, filePath); err != nil { + return false + } + + // Open and serve the file + file, err := fsys.Open(filePath) + if err != nil { + return false + } + defer file.Close() + + // Set content type + if contentType == "" { + contentType = s.getContentType(filePath) + } + w.Header().Set("Content-Type", contentType) + + // Copy file content to response + _, err = io.Copy(w, file) + return err == nil +} + +// getContentType determines the MIME type based on file extension +func (s *GBoxServer) getContentType(filePath string) string { + ext := strings.ToLower(filepath.Ext(filePath)) + switch ext { + case ".html", ".htm": + return "text/html; charset=utf-8" + case ".css": + return "text/css; charset=utf-8" + case ".js": + return "application/javascript; charset=utf-8" + case ".json": + return "application/json; charset=utf-8" + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".svg": + return "image/svg+xml" + case ".ico": + return "image/x-icon" + case ".woff": + return "font/woff" + case ".woff2": + return "font/woff2" + case ".ttf": + return "font/ttf" + case ".eot": + return "application/vnd.ms-fontobject" + default: + return "text/plain; charset=utf-8" + } +} + +// serveErrorPage serves a simple error page +func (s *GBoxServer) serveErrorPage(w http.ResponseWriter, requestedPath string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + + html := fmt.Sprintf(` + + + Not Found - GBOX Local Server + + + + +
+

404 - Not Found

+

The requested file "%s" was not found.

+ ← Back to Home +
+ +`, requestedPath) + + fmt.Fprint(w, html) +} + +// handleLiveViewHTML serves the live-view.html file +func (s *GBoxServer) handleLiveViewHTML(w http.ResponseWriter, r *http.Request) { + config := StaticFileConfig{ + BasePath: s.findLiveViewStaticPath(), + FallbackFile: "index.html", + RedirectPath: "/live-view", + } + s.serveStaticFile(w, r, config) +} + +// handleLiveViewAssets serves assets from the live-view static directory +func (s *GBoxServer) handleLiveViewAssets(w http.ResponseWriter, r *http.Request) { + liveViewPath := s.findLiveViewStaticPath() + if liveViewPath == "" { + http.NotFound(w, r) + return + } + + // Remove /assets/ prefix and serve from live-view static directory + requestedPath := strings.TrimPrefix(r.URL.Path, "/assets/") + filePath := filepath.Join(liveViewPath, "assets", requestedPath) + + // Set appropriate content type + contentType := s.getContentType(filePath) + + if s.tryServeFile(w, filePath, contentType) { + return + } + + http.NotFound(w, r) +} + +// handleLiveView serves the live-view application +func (s *GBoxServer) handleLiveView(w http.ResponseWriter, r *http.Request) { + liveViewPath := s.findLiveViewStaticPath() + + // Note: embedded files removed - using external files only + + // Fallback to external files or placeholder + config := StaticFileConfig{ + BasePath: liveViewPath, + FallbackFile: "live-view.html", + } + s.serveStaticFile(w, r, config) +} + +// handleAdbExposeUI serves the ADB Expose management interface +func (s *GBoxServer) handleAdbExposeUI(w http.ResponseWriter, r *http.Request) { + config := StaticFileConfig{ + BasePath: s.findStaticPath(), + FallbackFile: "adb-expose.html", + } + s.serveStaticFile(w, r, config) +} + +// handleStatic serves general static files from the server static directory +func (s *GBoxServer) handleStatic(w http.ResponseWriter, r *http.Request) { + config := StaticFileConfig{ + BasePath: s.findStaticPath(), + } + s.serveStaticFile(w, r, config) +} diff --git a/packages/cli/internal/server/ui_handlers.go b/packages/cli/internal/server/ui_handlers.go deleted file mode 100644 index 98d53393..00000000 --- a/packages/cli/internal/server/ui_handlers.go +++ /dev/null @@ -1,399 +0,0 @@ -package server - -import ( - "fmt" - "io" - "net/http" - "os" - "path/filepath" -) - -// handleLiveViewHTML serves the live-view.html file -func (s *GBoxServer) handleLiveViewHTML(w http.ResponseWriter, r *http.Request) { - // Try to find and serve the live-view static files - liveViewPath := s.findLiveViewStaticPath() - if liveViewPath != "" { - htmlFile := filepath.Join(liveViewPath, "index.html") - if _, err := os.Stat(htmlFile); err == nil { - // Read and serve the file with correct MIME type - file, err := os.Open(htmlFile) - if err == nil { - defer file.Close() - w.Header().Set("Content-Type", "text/html; charset=utf-8") - io.Copy(w, file) - return - } - } - } - - // If live-view is not built, redirect to /live-view - http.Redirect(w, r, "/live-view", http.StatusTemporaryRedirect) -} - -// handleLiveView serves the live-view application -func (s *GBoxServer) handleLiveView(w http.ResponseWriter, r *http.Request) { - // Try to find and serve the live-view static files - liveViewPath := s.findLiveViewStaticPath() - if liveViewPath != "" { - htmlFile := filepath.Join(liveViewPath, "index.html") - if _, err := os.Stat(htmlFile); err == nil { - // Read and serve the file with correct MIME type - file, err := os.Open(htmlFile) - if err == nil { - defer file.Close() - w.Header().Set("Content-Type", "text/html; charset=utf-8") - io.Copy(w, file) - return - } - } - } - - // If live-view is not built, show a placeholder - html := ` - - - Live View - GBox - - - - -
-

📱 Live View

-
-

The Live View interface is not yet built.

-

To enable the full WebRTC streaming interface:

- cd packages/live-view && pnpm install && pnpm build:static -

- After building, restart the server to load the interface. -

-
- ← Back to Home -
- -` - - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, html) -} - -// handleAdbExposeUI serves the ADB Expose management interface -func (s *GBoxServer) handleAdbExposeUI(w http.ResponseWriter, r *http.Request) { - html := ` - - - ADB Expose - GBox - - - - -
-

🔌 ADB Expose

- ← Back to Home -
- -
-
-

Add Port Forward

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- -
-

Active Port Forwards

-
-
No active port forwards
-
-
-
- - - -` - - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, html) -} \ No newline at end of file diff --git a/packages/cli/internal/server/version.go b/packages/cli/internal/server/version.go new file mode 100644 index 00000000..011c95e9 --- /dev/null +++ b/packages/cli/internal/server/version.go @@ -0,0 +1,43 @@ +package server + +import ( + "fmt" + "os" + "runtime" + "time" +) + +// BuildInfo contains build-time information +var BuildInfo = struct { + Version string + BuildTime string + GitCommit string + GoVersion string +}{ + Version: "dev", + BuildTime: time.Now().Format(time.RFC3339), + GitCommit: "unknown", + GoVersion: runtime.Version(), +} + +// GetBuildID returns a unique build identifier +func GetBuildID() string { + // Use build time + git commit + file size as build ID + // In production, this would be set by build scripts + execPath, err := os.Executable() + if err != nil { + return BuildInfo.BuildTime + "-" + BuildInfo.GitCommit + "-unknown" + } + + info, err := os.Stat(execPath) + if err != nil { + return BuildInfo.BuildTime + "-" + BuildInfo.GitCommit + "-unknown" + } + + // Use the same format as client for consistency + buildTime := info.ModTime().Format("2006-01-02T15:04:05") // No timezone, more stable + gitCommit := "unknown" + fileSize := info.Size() + + return fmt.Sprintf("%s-%s-%d", buildTime, gitCommit, fileSize) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 90566db7..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,22 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - tslib: - specifier: ^2.8.1 - version: 2.8.1 - -packages: - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - -snapshots: - - tslib@2.8.1: {} From 85c39329b4d4a08270b5bdb5c2f5ff0500563d0e Mon Sep 17 00:00:00 2001 From: Vangie Du Date: Sun, 14 Sep 2025 22:24:03 +0800 Subject: [PATCH 03/34] feat: enhance logging and add verbose flag support in CLI - Introduced a global verbose flag to enable detailed logging. - Refactored logging in control stream handling to utilize the new logger. - Updated WebRTC client to include ping measurement for latency tracking. - Improved UI components in live view for better user interaction and visibility. - Adjusted CSS styles for a more responsive layout and enhanced user experience. --- packages/cli/cmd/root.go | 16 + .../internal/device_connect/stream/control.go | 191 +++++++---- .../internal/device_connect/webrtc/bridge.go | 13 +- .../device_connect/webrtc/peer_connection.go | 11 +- packages/cli/internal/util/logger.go | 66 ++++ packages/cli/internal/util/verbose.go | 42 +++ .../src/components/AndroidLiveView.module.css | 72 ++++- .../src/components/AndroidLiveView.tsx | 28 +- .../src/components/ControlButtons.module.css | 44 +-- .../src/components/ControlButtons.tsx | 26 +- packages/live-view/src/lib/webrtc-client.ts | 303 +++++++++++------- 11 files changed, 595 insertions(+), 217 deletions(-) create mode 100644 packages/cli/internal/util/logger.go create mode 100644 packages/cli/internal/util/verbose.go diff --git a/packages/cli/cmd/root.go b/packages/cli/cmd/root.go index 4531d590..f9b88655 100644 --- a/packages/cli/cmd/root.go +++ b/packages/cli/cmd/root.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/babelcloud/gbox/packages/cli/config" + "github.com/babelcloud/gbox/packages/cli/internal/util" "github.com/babelcloud/gbox/packages/cli/internal/version" "github.com/spf13/cobra" ) @@ -16,11 +17,20 @@ var ( aliasMap = map[string]string{} scriptDir string + + // Global verbose flag + verbose bool rootCmd = &cobra.Command{ Use: "gbox", Short: "GBOX CLI Tool", Long: `GBOX CLI is a command-line tool for managing and operating box and mcp resources. It provides a set of commands to create, manage, and operate these resources.`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Initialize logger based on verbose flag + util.InitLogger(verbose) + // Setup global logger for existing log.Printf calls + util.SetupGlobalLogger() + }, RunE: func(cmd *cobra.Command, args []string) error { if cmd.Flag("version").Changed { info := version.ClientInfo() @@ -36,6 +46,11 @@ func Execute() error { return rootCmd.Execute() } +// IsVerbose returns the global verbose flag status +func IsVerbose() bool { + return verbose +} + func init() { exePath, err := os.Executable() if err != nil { @@ -58,6 +73,7 @@ func init() { scriptDir = filepath.Join(exeDir, "cmd", "script") } + rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Enable verbose logging") rootCmd.Flags().BoolP("version", "v", false, "Print version information and exit") for alias, cmd := range aliasMap { diff --git a/packages/cli/internal/device_connect/stream/control.go b/packages/cli/internal/device_connect/stream/control.go index 6a4de231..75796f5e 100644 --- a/packages/cli/internal/device_connect/stream/control.go +++ b/packages/cli/internal/device_connect/stream/control.go @@ -4,12 +4,12 @@ import ( "encoding/json" "fmt" "io" - "log" "net" "time" "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" + "github.com/babelcloud/gbox/packages/cli/internal/util" "github.com/pion/webrtc/v4" ) @@ -23,8 +23,12 @@ type ControlHandler struct { // NewControlHandler creates a new control stream handler func NewControlHandler(conn net.Conn, dataChannel *webrtc.DataChannel, screenWidth, screenHeight int) *ControlHandler { - log.Printf("NewControlHandler: creating with conn=%v, dataChannel=%v, screen=%dx%d", - conn != nil, dataChannel != nil, screenWidth, screenHeight) + logger := util.GetLogger() + logger.Debug("Creating control handler", + "conn_available", conn != nil, + "datachannel_available", dataChannel != nil, + "screen_width", screenWidth, + "screen_height", screenHeight) return &ControlHandler{ conn: conn, dataChannel: dataChannel, @@ -35,23 +39,23 @@ func NewControlHandler(conn net.Conn, dataChannel *webrtc.DataChannel, screenWid // HandleIncomingMessages handles control messages from WebRTC func (ch *ControlHandler) HandleIncomingMessages() { - log.Printf("HandleIncomingMessages called") + logger := util.GetLogger() + logger.Debug("HandleIncomingMessages called") + if ch.dataChannel == nil { - log.Printf("DataChannel is nil, cannot set up message handling") + logger.Error("DataChannel is nil, cannot set up message handling") return } - log.Printf("Setting up DataChannel message handling, DataChannel state: %s", ch.dataChannel.ReadyState()) - log.Printf("About to set OnMessage handler for DataChannel") + logger.Debug("Setting up DataChannel message handling", + "state", ch.dataChannel.ReadyState()) ch.dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) { - log.Printf("DataChannel message received, data length: %d", len(msg.Data)) - log.Printf("DataChannel message data: %s", string(msg.Data)) - - // Parse control message + // Parse control message first to determine if it's a ping var message map[string]interface{} if err := json.Unmarshal(msg.Data, &message); err != nil { - log.Printf("Failed to parse control message: %v", err) + logger := util.GetLogger() + logger.Error("Failed to parse control message", "error", err) return } @@ -68,15 +72,36 @@ func (ch *ControlHandler) HandleIncomingMessages() { case 9: msgType = "clipboard_set" default: - log.Printf("Unknown numeric control message type: %d", int(v)) + logger := util.GetLogger() + logger.Error("Unknown numeric control message type", "type", int(v)) return } default: - log.Printf("Control message missing or invalid type field: %v", message) + logger := util.GetLogger() + logger.Error("Control message missing or invalid type field", "message", message) return } - log.Printf("Received control message: type=%s", msgType) + // Log ping messages at debug level + if msgType == "ping" { + logger := util.GetLogger() + logger.Debug("Ping message received", + "data_length", len(msg.Data), + "data", string(msg.Data)) + } else { + // For non-ping messages, log appropriately + logger := util.GetLogger() + logger.Debug("DataChannel message received", + "data_length", len(msg.Data), + "data", string(msg.Data)) + + // Log touch/key events at debug level, others at info level + if msgType == "touch" || msgType == "key" { + logger.Debug("Received control message", "type", msgType) + } else { + logger.Info("Received control message", "type", msgType) + } + } switch msgType { case "ping": @@ -94,11 +119,12 @@ func (ch *ControlHandler) HandleIncomingMessages() { case "clipboard_set": ch.handleClipboardSet(message) default: - log.Printf("Unknown control message type: %s", msgType) + logger := util.GetLogger() + logger.Warn("Unknown control message type", "type", msgType) } }) - log.Printf("OnMessage handler set successfully for DataChannel") + logger.Debug("OnMessage handler set successfully for DataChannel") } // handlePingMessage handles ping/pong messages for connection health @@ -113,7 +139,11 @@ func (ch *ControlHandler) handlePingMessage(message map[string]interface{}) { if pongData, err := json.Marshal(pongResponse); err == nil { if ch.dataChannel != nil && ch.dataChannel.ReadyState() == webrtc.DataChannelStateOpen { if err := ch.dataChannel.Send(pongData); err != nil { - log.Printf("Failed to send pong response: %v", err) + logger := util.GetLogger() + logger.Error("Failed to send pong response", "error", err) + } else { + logger := util.GetLogger() + logger.Debug("Pong response sent", "ping_id", id) } } } @@ -127,7 +157,8 @@ func (ch *ControlHandler) handleKeyEvent(message map[string]interface{}) { metaState, _ := message["metaState"].(float64) repeat, _ := message["repeat"].(float64) - log.Printf("Key event: action=%s, keycode=%d, meta=%d", action, int(keycode), int(metaState)) + logger := util.GetLogger() + logger.Debug("Key event", "action", action, "keycode", int(keycode), "meta_state", int(metaState)) // Send to device via control connection if ch.conn != nil { @@ -143,14 +174,17 @@ func (ch *ControlHandler) handleTouchEvent(message map[string]interface{}) { pressure, _ := message["pressure"].(float64) pointerId, _ := message["pointerId"].(float64) - log.Printf("Touch event: action=%s, pos=(%.2f, %.2f), pressure=%.2f", action, x, y, pressure) + logger := util.GetLogger() + logger.Debug("Touch event", "action", action, "x", x, "y", y, "pressure", pressure) // Send to device via control connection if ch.conn != nil { - log.Printf("Sending touch event to device: action=%s, x=%.2f, y=%.2f", action, x, y) + logger := util.GetLogger() + logger.Debug("Sending touch event to device", "action", action, "x", x, "y", y) ch.SendTouchEventToDevice(action, x, y, pressure, int(pointerId)) } else { - log.Printf("Control connection is nil, cannot send touch event") + logger := util.GetLogger() + logger.Debug("Control connection is nil, cannot send touch event") } } @@ -161,14 +195,17 @@ func (ch *ControlHandler) handleScrollEvent(message map[string]interface{}) { hScroll, _ := message["hScroll"].(float64) vScroll, _ := message["vScroll"].(float64) - log.Printf("Scroll event: pos=(%.2f, %.2f), scroll=(%.2f, %.2f)", x, y, hScroll, vScroll) + logger := util.GetLogger() + logger.Debug("Scroll event", "x", x, "y", y, "hScroll", hScroll, "vScroll", vScroll) // Send to device via control connection if ch.conn != nil { - log.Printf("Sending scroll event to device: x=%.2f, y=%.2f, hScroll=%.2f, vScroll=%.2f", x, y, hScroll, vScroll) + logger := util.GetLogger() + logger.Debug("Sending scroll event to device", "x", x, "y", y, "hScroll", hScroll, "vScroll", vScroll) ch.SendScrollEventToDevice(x, y, hScroll, vScroll) } else { - log.Printf("Control connection is nil, cannot send scroll event - this is expected during initial connection setup") + logger := util.GetLogger() + logger.Debug("Control connection is nil, cannot send scroll event - this is expected during initial connection setup") // This is expected during initial connection setup, the connection will be updated later // We could queue the event here if needed, but for now just log it } @@ -176,27 +213,31 @@ func (ch *ControlHandler) handleScrollEvent(message map[string]interface{}) { // handleResetVideo handles video reset requests (keyframe) func (ch *ControlHandler) handleResetVideo(message map[string]interface{}) { - log.Println("Reset video requested (keyframe)") + logger := util.GetLogger() + logger.Info("Reset video requested (keyframe)") // This would trigger a keyframe request } // handleClipboardGet handles clipboard get requests func (ch *ControlHandler) handleClipboardGet(message map[string]interface{}) { - log.Println("Clipboard get requested") + logger := util.GetLogger() + logger.Info("Clipboard get requested") // TODO: Implement clipboard get functionality // This would get clipboard content from Android device and send it back } // handleClipboardSet handles clipboard set requests func (ch *ControlHandler) handleClipboardSet(message map[string]interface{}) { - log.Println("Clipboard set requested") + logger := util.GetLogger() + logger.Info("Clipboard set requested") // Check if this is a JSON format message (new format) or binary format (old format) if textInterface, ok := message["text"]; ok { // JSON format: {"type": "clipboard_set", "text": "你好", "paste": true} text, ok := textInterface.(string) if !ok { - log.Printf("Clipboard set message text field is not a string") + logger := util.GetLogger() + logger.Error("Clipboard set message text field is not a string") return } @@ -207,7 +248,8 @@ func (ch *ControlHandler) handleClipboardSet(message map[string]interface{}) { } } - log.Printf("Clipboard set (JSON format): text='%s', paste=%v", text, paste) + logger := util.GetLogger() + logger.Debug("Clipboard set (JSON format)", "text", text, "paste", paste) // Send clipboard data to Android device using scrcpy protocol ch.sendClipboardToDevice(text, paste) @@ -217,7 +259,8 @@ func (ch *ControlHandler) handleClipboardSet(message map[string]interface{}) { // Binary format: extract data from message dataInterface, ok := message["data"] if !ok { - log.Printf("Clipboard set message missing both text and data fields") + logger := util.GetLogger() + logger.Error("Clipboard set message missing both text and data fields") return } @@ -241,12 +284,14 @@ func (ch *ControlHandler) handleClipboardSet(message map[string]interface{}) { } } } else { - log.Printf("Clipboard set message data is not in expected format (array or map)") + logger := util.GetLogger() + logger.Error("Clipboard set message data is not in expected format (array or map)") return } if len(data) < 13 { - log.Printf("Clipboard set message data too short: %d bytes", len(data)) + logger := util.GetLogger() + logger.Error("Clipboard set message data too short", "bytes", len(data)) return } @@ -259,12 +304,13 @@ func (ch *ControlHandler) handleClipboardSet(message map[string]interface{}) { textLength := int(data[9])<<24 | int(data[10])<<16 | int(data[11])<<8 | int(data[12]) if len(data) < 13+textLength { - log.Printf("Clipboard set message data incomplete: expected %d bytes, got %d", 13+textLength, len(data)) + logger := util.GetLogger() + logger.Error("Clipboard set message data incomplete", "expected", 13+textLength, "got", len(data)) return } text := string(data[13 : 13+textLength]) - log.Printf("Clipboard set (binary format): sequence=%d, paste=%d, text='%s'", sequence, pasteFlag, text) + logger.Debug("Clipboard set (binary format)", "sequence", sequence, "paste", pasteFlag, "text", text) // Send clipboard data to Android device using scrcpy protocol ch.sendClipboardToDevice(text, pasteFlag == 1) @@ -273,7 +319,8 @@ func (ch *ControlHandler) handleClipboardSet(message map[string]interface{}) { // sendClipboardToDevice sends clipboard data to Android device func (ch *ControlHandler) sendClipboardToDevice(text string, paste bool) { if ch.conn == nil { - log.Printf("No connection available for clipboard operation") + logger := util.GetLogger() + logger.Error("No connection available for clipboard operation") return } @@ -317,7 +364,8 @@ func (ch *ControlHandler) sendClipboardToDevice(text string, paste bool) { // Debug: verify buffer size matches expected size expectedSize := 8 + 1 + 4 + textLength if len(buffer) != expectedSize { - log.Printf("ERROR: Buffer size mismatch! Expected: %d, Actual: %d", expectedSize, len(buffer)) + logger := util.GetLogger() + logger.Error("Buffer size mismatch", "expected", expectedSize, "actual", len(buffer)) } // Create control message @@ -328,17 +376,17 @@ func (ch *ControlHandler) sendClipboardToDevice(text string, paste bool) { } // Debug: log the buffer content - log.Printf("Clipboard buffer length: %d", len(buffer)) + logger := util.GetLogger() + logger.Debug("Clipboard buffer", "length", len(buffer)) if len(buffer) >= 20 { - log.Printf("Clipboard buffer first 20 bytes: %v", buffer[:20]) - log.Printf("Clipboard buffer last 20 bytes: %v", buffer[len(buffer)-20:]) + logger.Debug("Clipboard buffer details", "first_20_bytes", buffer[:20], "last_20_bytes", buffer[len(buffer)-20:]) } else { - log.Printf("Clipboard buffer content: %v", buffer) + logger.Debug("Clipboard buffer content", "buffer", buffer) } // Send to device ch.sendControlMessage(controlMsg) - log.Printf("Clipboard data sent to device: text='%s', paste=%v", text, paste) + logger.Info("Clipboard data sent to device", "text", text, "paste", paste) } // SendKeyEventToDevice sends key event to Android device using protocol package @@ -369,6 +417,14 @@ func (ch *ControlHandler) SendTouchEventToDevice(action string, x, y, pressure f return } + // Check if touch point is within screen bounds + if x < 0 || x > float64(ch.screenWidth) || y < 0 || y > float64(ch.screenHeight) { + logger := util.GetLogger() + logger.Debug("Touch event outside screen bounds, ignoring", + "x", x, "y", y, "screen_width", ch.screenWidth, "screen_height", ch.screenHeight) + return + } + touchEvent := &protocol.TouchEvent{ Action: action, X: x, @@ -389,7 +445,8 @@ func (ch *ControlHandler) SendTouchEventToDevice(action string, x, y, pressure f // SendScrollEventToDevice sends scroll event to Android device using protocol package func (ch *ControlHandler) SendScrollEventToDevice(x, y, hScroll, vScroll float64) { if ch.conn == nil { - log.Printf("SendScrollEventToDevice: control connection is nil") + logger := util.GetLogger() + logger.Debug("SendScrollEventToDevice: control connection is nil") return } @@ -400,8 +457,9 @@ func (ch *ControlHandler) SendScrollEventToDevice(x, y, hScroll, vScroll float64 VScroll: vScroll, } - log.Printf("SendScrollEventToDevice: creating scroll event with screen=%dx%d", ch.screenWidth, ch.screenHeight) - log.Printf("SendScrollEventToDevice: scroll event data: x=%.2f, y=%.2f, hScroll=%.2f, vScroll=%.2f", x, y, hScroll, vScroll) + logger := util.GetLogger() + logger.Debug("SendScrollEventToDevice: creating scroll event", "screen_width", ch.screenWidth, "screen_height", ch.screenHeight) + logger.Debug("SendScrollEventToDevice: scroll event data", "x", x, "y", y, "hScroll", hScroll, "vScroll", vScroll) controlMsg := &device.ControlMessage{ Type: protocol.ControlMsgTypeInjectScrollEvent, @@ -409,14 +467,15 @@ func (ch *ControlHandler) SendScrollEventToDevice(x, y, hScroll, vScroll float64 Data: protocol.EncodeScrollEvent(*scrollEvent, ch.screenWidth, ch.screenHeight), } - log.Printf("SendScrollEventToDevice: encoded data length=%d", len(controlMsg.Data)) + logger.Debug("SendScrollEventToDevice: encoded data", "length", len(controlMsg.Data)) ch.sendControlMessage(controlMsg) } // sendControlMessage sends a control message to the device func (ch *ControlHandler) sendControlMessage(msg *device.ControlMessage) { if ch.conn == nil { - log.Printf("Control connection is nil, cannot send message type %d", msg.Type) + logger := util.GetLogger() + logger.Error("Control connection is nil, cannot send message", "type", msg.Type) return } @@ -426,25 +485,28 @@ func (ch *ControlHandler) sendControlMessage(msg *device.ControlMessage) { buf[0] = byte(msg.Type) copy(buf[1:], msg.Data) - log.Printf("Sending control message to device: type=%d, data_len=%d", msg.Type, len(msg.Data)) + logger := util.GetLogger() + logger.Debug("Sending control message to device", "type", msg.Type, "data_len", len(msg.Data)) // Debug: log the actual data being sent to scrcpy server for clipboard messages if msg.Type == protocol.ControlMsgTypeSetClipboard { - log.Printf("Clipboard message - Total length: %d", len(buf)) + logger := util.GetLogger() + logger.Debug("Clipboard message details", "total_length", len(buf)) if len(buf) >= 20 { - log.Printf("First 20 bytes: %v", buf[:20]) - log.Printf("Last 20 bytes: %v", buf[len(buf)-20:]) + logger.Debug("Clipboard message data", "first_20_bytes", buf[:20], "last_20_bytes", buf[len(buf)-20:]) } else { - log.Printf("All data: %v", buf) + logger.Debug("Clipboard message all data", "data", buf) } } if _, err := ch.conn.Write(buf); err != nil { - log.Printf("Failed to send control message: %v", err) + logger := util.GetLogger() + logger.Error("Failed to send control message", "error", err) // Mark connection as invalid to prevent further attempts ch.conn = nil } else { - log.Printf("Control message sent successfully") + logger := util.GetLogger() + logger.Debug("Control message sent successfully") } } @@ -462,19 +524,28 @@ func (ch *ControlHandler) SendKeyFrameRequest() { func (ch *ControlHandler) UpdateScreenDimensions(width, height int) { ch.screenWidth = width ch.screenHeight = height - log.Printf("Updated screen dimensions: %dx%d", width, height) + if util.IsVerbose() { + logger := util.GetLogger() + logger.Debug("Updated screen dimensions", "width", width, "height", height) + } } // UpdateConnection updates the control connection func (ch *ControlHandler) UpdateConnection(conn net.Conn) { ch.conn = conn - log.Printf("Updated control connection") + if util.IsVerbose() { + logger := util.GetLogger() + logger.Debug("Updated control connection") + } } // UpdateDataChannel updates the DataChannel func (ch *ControlHandler) UpdateDataChannel(dataChannel *webrtc.DataChannel) { ch.dataChannel = dataChannel - log.Printf("Updated DataChannel") + if util.IsVerbose() { + logger := util.GetLogger() + logger.Debug("Updated DataChannel") + } } // HandleOutgoingMessages handles messages from device to WebRTC @@ -489,14 +560,16 @@ func (ch *ControlHandler) HandleOutgoingMessages() { n, err := ch.conn.Read(buffer) if err != nil { if err != io.EOF { - log.Printf("Control stream read error: %v", err) + logger := util.GetLogger() + logger.Error("Control stream read error", "error", err) } break } if n > 0 { // Process control response from device - log.Printf("Received control response: %d bytes", n) + logger := util.GetLogger() + logger.Debug("Received control response", "bytes", n) } } } diff --git a/packages/cli/internal/device_connect/webrtc/bridge.go b/packages/cli/internal/device_connect/webrtc/bridge.go index 6e007c19..09f85607 100644 --- a/packages/cli/internal/device_connect/webrtc/bridge.go +++ b/packages/cli/internal/device_connect/webrtc/bridge.go @@ -14,6 +14,7 @@ import ( "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" "github.com/babelcloud/gbox/packages/cli/internal/device_connect/stream" + "github.com/babelcloud/gbox/packages/cli/internal/util" "github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4/pkg/media" ) @@ -269,7 +270,9 @@ func (b *Bridge) startMediaStreaming(conn net.Conn) { // handleVideoStreamOptimized processes the first video connection func (b *Bridge) handleVideoStreamOptimized(conn net.Conn) { - log.Println("Processing optimized video stream") + if util.IsVerbose() { + log.Println("Processing optimized video stream") + } // Read codec ID from video stream conn.SetReadDeadline(time.Now().Add(10 * time.Second)) @@ -316,7 +319,9 @@ func (b *Bridge) handleVideoStreamOptimized(conn net.Conn) { // handleVideoStream processes video stream with codec func (b *Bridge) handleVideoStream(conn net.Conn, codecID uint32) { - log.Printf("Starting video stream handler with codec ID: 0x%08x", codecID) + if util.IsVerbose() { + log.Printf("Starting video stream handler with codec ID: 0x%08x", codecID) + } // Read video dimensions sizeData := make([]byte, 8) @@ -351,7 +356,9 @@ func (b *Bridge) handleVideoStream(conn net.Conn, codecID uint32) { // handleAudioStream processes audio stream func (b *Bridge) handleAudioStream(conn net.Conn, codecID uint32) { - log.Printf("Starting audio stream handler with codec ID: 0x%08x", codecID) + if util.IsVerbose() { + log.Printf("Starting audio stream handler with codec ID: 0x%08x", codecID) + } // Audio track should already be created in NewBridge if b.AudioTrack == nil { diff --git a/packages/cli/internal/device_connect/webrtc/peer_connection.go b/packages/cli/internal/device_connect/webrtc/peer_connection.go index e356d82c..b3d48607 100644 --- a/packages/cli/internal/device_connect/webrtc/peer_connection.go +++ b/packages/cli/internal/device_connect/webrtc/peer_connection.go @@ -5,6 +5,7 @@ import ( "log" "time" + "github.com/babelcloud/gbox/packages/cli/internal/util" "github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4/pkg/media" ) @@ -59,11 +60,17 @@ func CreatePeerConnection() (*webrtc.PeerConnection, error) { // Set up connection state logging pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) { - log.Printf("WebRTC Connection State: %s", s.String()) + // Only log important state changes or when verbose + if util.IsVerbose() || s == webrtc.PeerConnectionStateConnected || s == webrtc.PeerConnectionStateFailed || s == webrtc.PeerConnectionStateClosed { + log.Printf("WebRTC Connection State: %s", s.String()) + } }) pc.OnICEConnectionStateChange(func(s webrtc.ICEConnectionState) { - log.Printf("ICE Connection State: %s", s.String()) + // Only log important state changes or when verbose + if util.IsVerbose() || s == webrtc.ICEConnectionStateConnected || s == webrtc.ICEConnectionStateFailed || s == webrtc.ICEConnectionStateDisconnected { + log.Printf("ICE Connection State: %s", s.String()) + } }) return pc, nil diff --git a/packages/cli/internal/util/logger.go b/packages/cli/internal/util/logger.go new file mode 100644 index 00000000..1416721c --- /dev/null +++ b/packages/cli/internal/util/logger.go @@ -0,0 +1,66 @@ +package util + +import ( + "fmt" + "log" + "log/slog" +) + +// Logger wraps slog and provides traditional log.Printf style methods +type Logger struct { + slogLogger *slog.Logger +} + +// GetCompatLogger returns a logger that provides both slog and traditional log.Printf style methods +func GetCompatLogger() *Logger { + return &Logger{ + slogLogger: GetLogger(), + } +} + +// Printf provides log.Printf compatibility while using slog internally +func (l *Logger) Printf(format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + l.slogLogger.Info(msg) +} + +// Debugf logs at debug level +func (l *Logger) Debugf(format string, v ...interface{}) { + if IsVerbose() { + msg := fmt.Sprintf(format, v...) + l.slogLogger.Debug(msg) + } +} + +// Errorf logs at error level +func (l *Logger) Errorf(format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + l.slogLogger.Error(msg) +} + +// Warnf logs at warn level +func (l *Logger) Warnf(format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + l.slogLogger.Warn(msg) +} + +// Infof logs at info level +func (l *Logger) Infof(format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + l.slogLogger.Info(msg) +} + +// SetupGlobalLogger replaces the standard log package logger +func SetupGlobalLogger() { + logger := GetCompatLogger() + log.SetOutput(&logWriter{logger: logger.slogLogger}) +} + +type logWriter struct { + logger *slog.Logger +} + +func (w *logWriter) Write(p []byte) (n int, err error) { + w.logger.Info(string(p)) + return len(p), nil +} \ No newline at end of file diff --git a/packages/cli/internal/util/verbose.go b/packages/cli/internal/util/verbose.go new file mode 100644 index 00000000..28a54fcb --- /dev/null +++ b/packages/cli/internal/util/verbose.go @@ -0,0 +1,42 @@ +package util + +import ( + "log/slog" + "os" +) + +var logger *slog.Logger + +// InitLogger initializes the global slog logger with appropriate level +func InitLogger(verbose bool) { + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, // Default level + } + + if verbose { + opts.Level = slog.LevelDebug + } + + handler := slog.NewTextHandler(os.Stdout, opts) + logger = slog.New(handler) + slog.SetDefault(logger) +} + +// GetLogger returns the configured logger instance +func GetLogger() *slog.Logger { + if logger == nil { + // Fallback initialization with INFO level + InitLogger(false) + } + return logger +} + +// IsVerbose checks if verbose mode is enabled by looking at command line arguments +func IsVerbose() bool { + for _, arg := range os.Args { + if arg == "--verbose" { + return true + } + } + return false +} \ No newline at end of file diff --git a/packages/live-view/src/components/AndroidLiveView.module.css b/packages/live-view/src/components/AndroidLiveView.module.css index 7796f71e..f53c5793 100644 --- a/packages/live-view/src/components/AndroidLiveView.module.css +++ b/packages/live-view/src/components/AndroidLiveView.module.css @@ -16,8 +16,10 @@ .mainContent { flex: 1; display: flex; - flex-direction: column; + flex-direction: row; background: #000; + overflow: visible; + position: relative; } .videoContainer { @@ -29,7 +31,7 @@ justify-content: center; overflow: hidden; min-height: 0; - padding: 20px; + padding: 20px 20px 80px 20px; } .videoWrapper { @@ -39,10 +41,11 @@ justify-content: center; width: 100%; height: 100%; - max-width: calc(100% - 40px); - max-height: calc(100% - 40px); + max-width: 100%; + max-height: 100%; } + .video { object-fit: contain; display: block; @@ -86,14 +89,67 @@ height: 24px; } -.stats { +.controlsArea { + width: 100px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + position: relative; +} + +.statsArea { position: absolute; - bottom: 10px; - right: 10px; + bottom: 0; + right: 0; + left: 0; + height: 60px; + display: flex; + align-items: flex-end; + justify-content: flex-end; + padding: 10px 20px; + pointer-events: none; + z-index: 10; +} + +.stats { background: rgba(0, 0, 0, 0.7); padding: 8px 12px; border-radius: 5px; font-size: 12px; color: #ccc; - z-index: 10; + pointer-events: auto; +} + +.toggleButton { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 40px; + height: 40px; + border: none; + background: rgba(0, 0, 0, 0.8); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + color: white; + z-index: 1000; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.toggleButton:hover { + background: rgba(255, 255, 255, 0.2); + transform: translateY(-50%) scale(1.1); + color: #4CAF50; +} + +.toggleButton:active { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-50%) scale(0.95); } \ No newline at end of file diff --git a/packages/live-view/src/components/AndroidLiveView.tsx b/packages/live-view/src/components/AndroidLiveView.tsx index c12258ec..b048a13d 100644 --- a/packages/live-view/src/components/AndroidLiveView.tsx +++ b/packages/live-view/src/components/AndroidLiveView.tsx @@ -94,6 +94,7 @@ export const AndroidLiveView: React.FC = ({ }, [isConnected]); + // Initialize WebRTC client useEffect(() => { if (!videoRef.current) return; @@ -218,12 +219,6 @@ export const AndroidLiveView: React.FC = ({ style={{ touchAction: 'none', outline: 'none' }} tabIndex={0} /> - {showControls && showAndroidControls && isConnected && ( - - )}
= ({ top: touchPosition.y, }} /> - - {showControls && ( +
+ + {showControls && showAndroidControls && isConnected && ( +
+ {}} + /> +
+ )} + + {showControls && ( +
Resolution: {stats.resolution || '-'}
FPS: {stats.fps || '-'}
Latency: {stats.latency ? `${stats.latency}ms` : '-'}
- )} -
+
+ )}
); diff --git a/packages/live-view/src/components/ControlButtons.module.css b/packages/live-view/src/components/ControlButtons.module.css index a98da781..3025dce1 100644 --- a/packages/live-view/src/components/ControlButtons.module.css +++ b/packages/live-view/src/components/ControlButtons.module.css @@ -1,43 +1,44 @@ .controlButtons { - position: fixed; - right: 0; - top: 50%; - transform: translateY(-50%); display: flex; flex-direction: column; - gap: 8px; - background: rgba(0, 0, 0, 0.7); - padding: 8px 0 8px 12px; - border-radius: 8px 0 0 8px; + gap: 6px; + background: rgba(45, 45, 45, 0.95); + padding: 12px 8px; + border-radius: 16px; z-index: 1000; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); margin: 0; box-sizing: border-box; + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.15); + min-width: 60px; } + .controlBtn { - width: 40px; - height: 40px; + width: 44px; + height: 44px; border: none; - background: rgba(255, 255, 255, 0.1); - border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + border-radius: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: all 0.2s ease; + transition: all 0.3s ease; padding: 0; margin: 0; - color: white; + color: rgba(255, 255, 255, 0.9); position: relative; box-sizing: border-box; outline: none; } .controlBtn:hover { - background: rgba(255, 255, 255, 0.2); - transform: scale(1.1); + background: rgba(255, 255, 255, 0.15); + transform: translateY(-2px); color: #4CAF50; + box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); } .controlBtn:active { @@ -46,19 +47,20 @@ } .controlBtn svg { - width: 24px; - height: 24px; + width: 22px; + height: 22px; display: block; margin: 0; padding: 0; } .separator { - width: 40px; + width: 44px; height: 1px; - background: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.15); cursor: default; border-radius: 0; + margin: 4px 0; } .controlBtn.active { diff --git a/packages/live-view/src/components/ControlButtons.tsx b/packages/live-view/src/components/ControlButtons.tsx index 7de3f7c3..b6440b19 100644 --- a/packages/live-view/src/components/ControlButtons.tsx +++ b/packages/live-view/src/components/ControlButtons.tsx @@ -4,9 +4,11 @@ import styles from './ControlButtons.module.css'; interface ControlButtonsProps { onAction: (action: string) => void; onIMESwitch?: () => void; + isVisible?: boolean; + onToggleVisibility?: () => void; } -export const ControlButtons: React.FC = ({ onAction, onIMESwitch }) => { +export const ControlButtons: React.FC = ({ onAction, onIMESwitch, isVisible = true, onToggleVisibility }) => { const buttons = [ { id: 'power', title: 'Power', icon: PowerIcon }, { id: 'volume_up', title: 'Volume Up', icon: VolumeUpIcon }, @@ -30,6 +32,8 @@ export const ControlButtons: React.FC = ({ onAction, onIMES const handleClick = () => { if (button.isIMESwitch && onIMESwitch) { onIMESwitch(); + } else if (button.isToggle && onToggleVisibility) { + onToggleVisibility(); } else { onAction(button.id); } @@ -112,3 +116,23 @@ const IMESwitchIcon = () => ( ); + +const HideIcon = () => ( + + + + + + + + +); + +const ShowIcon = () => ( + + + + + + +); diff --git a/packages/live-view/src/lib/webrtc-client.ts b/packages/live-view/src/lib/webrtc-client.ts index 64f22742..8cc47ac1 100644 --- a/packages/live-view/src/lib/webrtc-client.ts +++ b/packages/live-view/src/lib/webrtc-client.ts @@ -109,21 +109,18 @@ export class WebRTCClient { console.log(`[WebRTC] Creating WebSocket connection to: ${fullWsUrl}`); this.ws = new WebSocket(fullWsUrl); - // Create WebRTC peer connection with ultra-low-latency optimizations + // Create WebRTC peer connection with balanced low-latency settings this.pc = new RTCPeerConnection({ iceServers: [], bundlePolicy: "max-bundle", rtcpMuxPolicy: "require", - iceCandidatePoolSize: 0, // Disable candidate pool for faster connection - // Ultra-low latency optimizations - iceTransportPolicy: "all", + iceCandidatePoolSize: 1, // Use small candidate pool for stability }); - // Create data channel for control messages with ultra-low latency settings + // Create data channel for control messages this.dataChannel = this.pc.createDataChannel("control", { ordered: false, // Allow out-of-order delivery for lower latency maxRetransmits: 0, // No retransmissions for lower latency - // Note: Cannot use both maxRetransmits and maxPacketLifeTime together }); this.setupDataChannel(); console.log("[WebRTC] Created data channel: control"); @@ -136,36 +133,12 @@ export class WebRTCClient { direction: "recvonly", }); - // Set ultra-low latency hints and optimizations + // Set reasonable low latency hints (not ultra-aggressive) if ("playoutDelayHint" in videoTransceiver.receiver) { - (videoTransceiver.receiver as any).playoutDelayHint = 0; + (videoTransceiver.receiver as any).playoutDelayHint = 0.1; // 100ms instead of 0 } if ("playoutDelayHint" in audioTransceiver.receiver) { - (audioTransceiver.receiver as any).playoutDelayHint = 0; - } - - // Set jitter buffer settings for lower latency - if ("jitterBufferTarget" in videoTransceiver.receiver) { - (videoTransceiver.receiver as any).jitterBufferTarget = 0; - } - if ("jitterBufferTarget" in audioTransceiver.receiver) { - (audioTransceiver.receiver as any).jitterBufferTarget = 0; - } - - // Configure transceivers for low latency - if (videoTransceiver.sender && "setParameters" in videoTransceiver.sender) { - try { - const params = videoTransceiver.sender.getParameters(); - if (params.encodings && params.encodings.length > 0) { - // Optimize for ultra-low latency - params.encodings[0].maxBitrate = 12000000; // 12 Mbps max for better quality - params.encodings[0].maxFramerate = 60; - params.encodings[0].scaleResolutionDownBy = 1; // No downscaling - videoTransceiver.sender.setParameters(params); - } - } catch (e) { - console.warn("Failed to set video sender parameters:", e); - } + (audioTransceiver.receiver as any).playoutDelayHint = 0.1; // 100ms instead of 0 } this.setupWebRTCHandlers(); @@ -230,30 +203,19 @@ export class WebRTCClient { if (event.track.kind === "video" && this.videoElement) { console.log("[WebRTC] Video track received, setting up playback"); event.track.enabled = true; + + // Basic video element setup this.videoElement.autoplay = true; this.videoElement.muted = false; this.videoElement.playsInline = true; this.videoElement.controls = false; + this.videoElement.preload = "auto"; this.videoElement.srcObject = event.streams[0]; - // Optimize video element for ultra-low latency - this.videoElement.preload = "none"; - this.videoElement.defaultMuted = false; - // Additional low-latency optimizations + // Basic styling this.videoElement.style.objectFit = "contain"; this.videoElement.style.background = "black"; - // Disable buffering optimizations that add latency - if ("webkitPreservesPitch" in this.videoElement) { - (this.videoElement as any).webkitPreservesPitch = false; - } - - // Set low latency playback hints if available - if ("requestVideoFrameCallback" in this.videoElement) { - // Use modern frame callback for better timing - (this.videoElement as any).requestVideoFrameCallback(() => { - // Frame rendered callback for timing analysis - }); - } + console.log("[WebRTC] Video srcObject set"); this.videoElement.onloadedmetadata = () => { @@ -264,9 +226,11 @@ export class WebRTCClient { if (width && height) { this.onStatsUpdate?.({ resolution: `${width}x${height}` }); } + }; - // Optimize buffering for low latency - this.optimizeVideoBuffering(); + this.videoElement.onplaying = () => { + // Reset stall detection when video starts playing + this.lastVideoTime = this.videoElement?.currentTime || 0; }; this.onConnectionStateChange?.("connected", undefined); @@ -515,8 +479,28 @@ export class WebRTCClient { this.dataChannel.onmessage = (event) => { try { const message = JSON.parse(event.data); - if (message.type === "pong" && message.id) { - // Handle ping response + + // Handle ping responses for latency measurement + if (message.type === "pong" && message.id && this.pendingPings?.has(message.id)) { + const pingStart = this.pendingPings.get(message.id); + if (pingStart) { + const latency = performance.now() - pingStart; + + // Store ping time for averaging + if (!this.pingTimes) this.pingTimes = []; + this.pingTimes.push(latency); + + // Keep only last 5 ping times + if (this.pingTimes.length > 5) { + this.pingTimes.shift(); + } + + // Update latency display with average + const avgLatency = this.pingTimes.reduce((a, b) => a + b, 0) / this.pingTimes.length; + this.onStatsUpdate?.({ latency: Math.round(avgLatency) }); + + this.pendingPings.delete(message.id); + } } } catch (e) { // Not JSON @@ -524,6 +508,60 @@ export class WebRTCClient { }; } + // Ping measurement properties + private pingTimes: number[] = []; + private pingInterval: number | null = null; + private pendingPings: Map | null = null; + + private startPingMeasurement(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + } + + this.pingTimes = []; + this.pendingPings = new Map(); + + // Measure ping every 2 seconds + this.pingInterval = window.setInterval(() => { + this.measurePing(); + }, 2000); + } + + private measurePing(): void { + if (!this.dataChannel || this.dataChannel.readyState !== 'open') { + return; + } + + const pingStart = performance.now(); + const pingId = Math.random().toString(36).substring(2, 11); + + // Send ping message + this.dataChannel.send(JSON.stringify({ + type: 'ping', + id: pingId, + timestamp: pingStart + })); + + // Store ping start time + if (!this.pendingPings) { + this.pendingPings = new Map(); + } + this.pendingPings.set(pingId, pingStart); + + // Clean up old pings after 5 seconds + setTimeout(() => { + this.pendingPings?.delete(pingId); + }, 5000); + } + + private stopPingMeasurement(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + this.pendingPings?.clear(); + } + sendControlMessage(message: ControlMessage): void { if (!this.dataChannel) { console.warn("[WebRTC] Data channel not available"); @@ -625,6 +663,13 @@ export class WebRTCClient { return; } + // Only handle left mouse button (button 0) for touch simulation + // Right click (button 2) and middle click (button 1) should be ignored + if ((action === "down" || action === "up") && event.button !== 0) { + console.log(`[WebRTC] Ignoring non-left mouse button: ${event.button}`); + return; + } + // Check if peer connection is in a valid state if ( this.pc.connectionState === "closed" || @@ -715,7 +760,14 @@ export class WebRTCClient { const x = relativeX / displayWidth; const y = relativeY / displayHeight; - // Ensure coordinates are within bounds + // Only send touch events if the click is within the actual video display area + if (x < 0 || x > 1 || y < 0 || y > 1) { + // Click is in the black bars (letterbox/pillarbox), ignore it + console.log(`[WebRTC] Click outside video area ignored: x=${x.toFixed(3)}, y=${y.toFixed(3)}`); + return; + } + + // Ensure coordinates are within bounds (should already be, but safety check) const clampedX = Math.max(0, Math.min(1, x)); const clampedY = Math.max(0, Math.min(1, y)); @@ -789,11 +841,22 @@ export class WebRTCClient { const x = relativeX / displayWidth; const y = relativeY / displayHeight; + // Only send touch events if the touch is within the actual video display area + if (x < 0 || x > 1 || y < 0 || y > 1) { + // Touch is in the black bars (letterbox/pillarbox), ignore it + console.log(`[WebRTC] Touch outside video area ignored: x=${x.toFixed(3)}, y=${y.toFixed(3)}`); + return; + } + + // Ensure coordinates are within bounds (should already be, but safety check) + const clampedX = Math.max(0, Math.min(1, x)); + const clampedY = Math.max(0, Math.min(1, y)); + this.sendControlMessage({ type: "touch", action, - x: Math.max(0, Math.min(1, x)), - y: Math.max(0, Math.min(1, y)), + x: clampedX, + y: clampedY, pressure: action === "down" || action === "move" ? 1.0 : 0.0, pointerId: 0, }); @@ -811,40 +874,59 @@ export class WebRTCClient { this.sendControlMessage({ type: "reset_video" }); } - // Optimize video buffering for low latency - private optimizeVideoBuffering(): void { - if (!this.videoElement) return; + // Check for video stalls and request keyframe if needed + private checkForVideoStall(): void { + if (!this.videoElement || this.videoElement.paused) return; - // Try to minimize buffering - try { - // Set current time to reduce buffer - if (this.videoElement.buffered.length > 0) { - const bufferedEnd = this.videoElement.buffered.end(0); - const currentTime = this.videoElement.currentTime; - - // If we have too much buffered content, seek to reduce it - if (bufferedEnd - currentTime > 0.5) { - // More than 500ms buffered - console.log("[WebRTC] Reducing video buffer for lower latency"); - this.videoElement.currentTime = bufferedEnd - 0.1; // Keep only 100ms buffer - } - } - } catch (e) { - console.warn("[WebRTC] Failed to optimize video buffering:", e); + const currentTime = this.videoElement.currentTime; + const timeDiff = currentTime - this.lastVideoTime; + + // If video time hasn't advanced by at least 0.1 seconds in 2 seconds, consider it stalled + if (timeDiff < 0.1) { + console.log('[WebRTC] Video appears stalled, requesting keyframe'); + this.requestKeyframe(); } + + this.lastVideoTime = currentTime; } + private lastVideoTime = 0; + private stallCheckInterval: number | null = null; + private startStats(): void { if (this.statsInterval) { clearInterval(this.statsInterval); } - // Update stats more frequently for better responsiveness + // Update stats every second this.statsInterval = window.setInterval(() => { this.updateStats(); - // Also optimize buffering periodically - this.optimizeVideoBuffering(); - }, 500); // Update every 500ms instead of 1000ms + }, 1000); + + // Start stall detection + this.startStallDetection(); + // Start ping measurement for accurate latency + this.startPingMeasurement(); + } + + private startStallDetection(): void { + if (this.stallCheckInterval) { + clearInterval(this.stallCheckInterval); + } + + this.lastVideoTime = this.videoElement?.currentTime || 0; + + // Check for stalls every 2 seconds + this.stallCheckInterval = window.setInterval(() => { + this.checkForVideoStall(); + }, 2000); + } + + private stopStallDetection(): void { + if (this.stallCheckInterval) { + clearInterval(this.stallCheckInterval); + this.stallCheckInterval = null; + } } private lastFramesDecoded = 0; @@ -858,7 +940,7 @@ export class WebRTCClient { const stats = await this.pc.getStats(); let fps = 0; let resolution = ""; - let latency = 0; + let webrtcLatency = 0; stats.forEach((report: any) => { if ( @@ -868,41 +950,25 @@ export class WebRTCClient { const width = report.frameWidth || 0; const height = report.frameHeight || 0; - // Calculate FPS from frames decoded difference - const currentTime = Date.now(); - const currentFramesDecoded = report.framesDecoded || 0; - - if (this.lastFramesDecoded > 0 && this.lastStatsTime > 0) { - const timeDiff = (currentTime - this.lastStatsTime) / 1000; // in seconds - const framesDiff = currentFramesDecoded - this.lastFramesDecoded; - if (timeDiff > 0 && framesDiff >= 0) { - fps = Math.round(framesDiff / timeDiff); - } - } - - this.lastFramesDecoded = currentFramesDecoded; - this.lastStatsTime = currentTime; - - // Use framesPerSecond if available as fallback - if (fps === 0 && report.framesPerSecond) { + // Use direct framesPerSecond if available (most reliable) + if (report.framesPerSecond) { fps = Math.round(report.framesPerSecond); } - - // Additional fallback: use framesReceived if framesDecoded is not available - if (fps === 0 && report.framesReceived) { - const currentFramesReceived = report.framesReceived || 0; - if ( - this.lastFramesReceived !== undefined && - this.lastStatsTime > 0 - ) { - const timeDiff = (currentTime - this.lastStatsTime) / 1000; - const framesDiff = - currentFramesReceived - this.lastFramesReceived; + // Fallback: calculate FPS from frames decoded difference + else if (report.framesDecoded) { + const currentTime = Date.now(); + const currentFramesDecoded = report.framesDecoded || 0; + + if (this.lastFramesDecoded > 0 && this.lastStatsTime > 0) { + const timeDiff = (currentTime - this.lastStatsTime) / 1000; // in seconds + const framesDiff = currentFramesDecoded - this.lastFramesDecoded; if (timeDiff > 0 && framesDiff >= 0) { fps = Math.round(framesDiff / timeDiff); } } - this.lastFramesReceived = currentFramesReceived; + + this.lastFramesDecoded = currentFramesDecoded; + this.lastStatsTime = currentTime; } if (width && height) { @@ -910,12 +976,17 @@ export class WebRTCClient { } } - // Get latency from candidate-pair stats - if (report.type === "candidate-pair" && report.state === "succeeded") { - latency = Math.round(report.currentRoundTripTime * 1000) || 0; // Convert to ms + // Get latency from candidate-pair stats (as fallback) + if (report.type === "candidate-pair" && report.state === "succeeded" && report.currentRoundTripTime) { + webrtcLatency = Math.round(report.currentRoundTripTime * 1000); // Convert to ms } }); + // Use ping-pong latency if available, otherwise use WebRTC latency + const latency = this.pingTimes.length > 0 ? + Math.round(this.pingTimes.reduce((a, b) => a + b, 0) / this.pingTimes.length) : + webrtcLatency; + this.onStatsUpdate?.({ fps, resolution, latency }); } catch (err) { console.warn("Failed to get WebRTC stats:", err); @@ -993,12 +1064,15 @@ export class WebRTCClient { this.isConnected = false; this.onConnectionStateChange?.("disconnected", undefined); - // Stop stats collection + // Stop all intervals if (this.statsInterval) { clearInterval(this.statsInterval); this.statsInterval = null; } + this.stopStallDetection(); + this.stopPingMeasurement(); + // Close data channel if (this.dataChannel) { try { @@ -1048,6 +1122,7 @@ export class WebRTCClient { this.lastFramesDecoded = 0; this.lastFramesReceived = 0; this.lastStatsTime = 0; + this.lastVideoTime = 0; console.log("[WebRTC] Disconnect completed"); } @@ -1063,6 +1138,8 @@ export class WebRTCClient { cleanup(): void { this.stopReconnection(); + this.stopStallDetection(); + this.stopPingMeasurement(); if (this.isConnected || this.pc || this.ws) { this.disconnect(true); } From 6bf9004bd1e73e3a39e0a53fcc05064b49cdf763 Mon Sep 17 00:00:00 2001 From: Vangie Du Date: Thu, 18 Sep 2025 22:36:47 +0800 Subject: [PATCH 04/34] feat: enhance device connection and streaming capabilities - Updated device connection handling to utilize a new bridge manager for WebRTC. - Introduced new transport layers for H.264 and MSE streaming. - Refactored existing code to improve modularity and maintainability. - Added new utility functions for random string generation and verbose logging. - Enhanced the live view interface with improved layout and functionality. - Updated various components to support new streaming modes and improved user experience. --- packages/cli/.gitignore | 3 +- packages/cli/Makefile | 9 +- packages/cli/cmd/root.go | 2 +- packages/cli/go.mod | 1 + packages/cli/go.sum | 2 + .../internal/device_connect/api/handlers.go | 8 +- .../cli/internal/device_connect/api/server.go | 40 +- .../internal/device_connect/api/websocket.go | 26 +- .../internal/device_connect/core/sample.go | 20 + .../internal/device_connect/core/source.go | 54 + .../device_connect/device/connection.go | 116 +- .../device_connect/pipeline/broadcaster.go | 264 +++ .../device_connect/pipeline/pipeline.go | 120 ++ .../device_connect/protocol/control.go | 26 +- .../internal/device_connect/scrcpy/control.go | 46 + .../device_connect/scrcpy/handshake.go | 19 + .../internal/device_connect/scrcpy/manager.go | 107 ++ .../internal/device_connect/scrcpy/source.go | 475 +++++ .../internal/device_connect/stream/audio.go | 99 -- .../internal/device_connect/stream/control.go | 575 ------ .../internal/device_connect/stream/video.go | 173 -- .../transport/control/clipboard.go | 59 + .../transport/control/handler.go | 185 ++ .../device_connect/transport/control/input.go | 247 +++ .../transport/control/interface.go | 38 + .../transport/h264/annexb_to_avc.go | 261 +++ .../transport/h264/annexb_to_avc_test.go | 213 +++ .../device_connect/transport/h264/control.go | 46 + .../transport/h264/handler_avc.go | 130 ++ .../transport/h264/handler_http.go | 85 + .../transport/h264/handler_ws.go | 104 ++ .../device_connect/transport/h264/nal.go | 208 +++ .../transport/h264/transport.go | 71 + .../device_connect/transport/mse/control.go | 46 + .../device_connect/transport/mse/handler.go | 102 ++ .../transport/mse/packager_ffmpeg.go | 186 ++ .../device_connect/transport/mse/transport.go | 47 + .../device_connect/transport/webrtc/bridge.go | 111 ++ .../transport/webrtc/control_wrapper.go | 49 + .../transport/webrtc/manager.go | 118 ++ .../{ => transport}/webrtc/peer_connection.go | 52 +- .../transport/webrtc/transport.go | 278 +++ .../internal/device_connect/webrtc/bridge.go | 837 --------- .../internal/device_connect/webrtc/debug.go | 25 - .../internal/device_connect/webrtc/manager.go | 86 - .../cli/internal/server/device_connect.go | 497 ------ .../server/{ => handlers}/adb_expose.go | 246 ++- packages/cli/internal/server/handlers/api.go | 146 ++ .../cli/internal/server/handlers/assets.go | 47 + .../cli/internal/server/handlers/boxes.go | 83 + .../cli/internal/server/handlers/devices.go | 256 +++ .../internal/server/handlers/interfaces.go | 47 + .../cli/internal/server/handlers/pages.go | 87 + .../cli/internal/server/handlers/streaming.go | 1547 +++++++++++++++++ .../cli/internal/server/handlers/utils.go | 13 + .../cli/internal/server/handlers/webrtc.go | 330 ++++ packages/cli/internal/server/router/adb.go | 28 + packages/cli/internal/server/router/api.go | 57 + packages/cli/internal/server/router/assets.go | 33 + packages/cli/internal/server/router/pages.go | 37 + .../cli/internal/server/router/path_utils.go | 54 + packages/cli/internal/server/router/router.go | 39 + .../cli/internal/server/router/streaming.go | 52 + packages/cli/internal/server/server.go | 282 ++- .../internal/server/static/adb-expose.html | 1 + .../cli/internal/server/static/index.html | 2 +- .../cli/internal/server/static/live-view.html | 71 - .../cli/internal/server/static_handlers.go | 255 --- packages/cli/internal/util/logger.go | 47 +- packages/cli/internal/util/random.go | 20 + packages/cli/internal/util/verbose.go | 142 +- packages/live-view/index.html | 11 +- packages/live-view/pnpm-lock.yaml | 11 +- .../src/components/AndroidLiveView.module.css | 241 ++- .../src/components/AndroidLiveView.tsx | 460 +++-- .../src/components/ControlButtons.module.css | 16 +- .../src/components/ControlButtons.tsx | 2 + .../src/components/DeviceList.module.css | 13 +- .../live-view/src/components/DeviceList.tsx | 32 +- .../live-view/src/hooks/useDeviceManager.ts | 12 +- packages/live-view/src/lib/h264-client.ts | 456 +++++ packages/live-view/src/lib/mse-client.ts | 1159 ++++++++++++ packages/live-view/src/lib/webrtc-client.ts | 224 ++- packages/live-view/src/main.tsx | 2 + packages/live-view/src/types.ts | 1 + packages/live-view/vite.config.ts | 29 +- 86 files changed, 9607 insertions(+), 3250 deletions(-) create mode 100644 packages/cli/internal/device_connect/core/sample.go create mode 100644 packages/cli/internal/device_connect/core/source.go create mode 100644 packages/cli/internal/device_connect/pipeline/broadcaster.go create mode 100644 packages/cli/internal/device_connect/pipeline/pipeline.go create mode 100644 packages/cli/internal/device_connect/scrcpy/control.go create mode 100644 packages/cli/internal/device_connect/scrcpy/handshake.go create mode 100644 packages/cli/internal/device_connect/scrcpy/manager.go create mode 100644 packages/cli/internal/device_connect/scrcpy/source.go delete mode 100644 packages/cli/internal/device_connect/stream/audio.go delete mode 100644 packages/cli/internal/device_connect/stream/control.go delete mode 100644 packages/cli/internal/device_connect/stream/video.go create mode 100644 packages/cli/internal/device_connect/transport/control/clipboard.go create mode 100644 packages/cli/internal/device_connect/transport/control/handler.go create mode 100644 packages/cli/internal/device_connect/transport/control/input.go create mode 100644 packages/cli/internal/device_connect/transport/control/interface.go create mode 100644 packages/cli/internal/device_connect/transport/h264/annexb_to_avc.go create mode 100644 packages/cli/internal/device_connect/transport/h264/annexb_to_avc_test.go create mode 100644 packages/cli/internal/device_connect/transport/h264/control.go create mode 100644 packages/cli/internal/device_connect/transport/h264/handler_avc.go create mode 100644 packages/cli/internal/device_connect/transport/h264/handler_http.go create mode 100644 packages/cli/internal/device_connect/transport/h264/handler_ws.go create mode 100644 packages/cli/internal/device_connect/transport/h264/nal.go create mode 100644 packages/cli/internal/device_connect/transport/h264/transport.go create mode 100644 packages/cli/internal/device_connect/transport/mse/control.go create mode 100644 packages/cli/internal/device_connect/transport/mse/handler.go create mode 100644 packages/cli/internal/device_connect/transport/mse/packager_ffmpeg.go create mode 100644 packages/cli/internal/device_connect/transport/mse/transport.go create mode 100644 packages/cli/internal/device_connect/transport/webrtc/bridge.go create mode 100644 packages/cli/internal/device_connect/transport/webrtc/control_wrapper.go create mode 100644 packages/cli/internal/device_connect/transport/webrtc/manager.go rename packages/cli/internal/device_connect/{ => transport}/webrtc/peer_connection.go (69%) create mode 100644 packages/cli/internal/device_connect/transport/webrtc/transport.go delete mode 100644 packages/cli/internal/device_connect/webrtc/bridge.go delete mode 100644 packages/cli/internal/device_connect/webrtc/debug.go delete mode 100644 packages/cli/internal/device_connect/webrtc/manager.go delete mode 100644 packages/cli/internal/server/device_connect.go rename packages/cli/internal/server/{ => handlers}/adb_expose.go (56%) create mode 100644 packages/cli/internal/server/handlers/api.go create mode 100644 packages/cli/internal/server/handlers/assets.go create mode 100644 packages/cli/internal/server/handlers/boxes.go create mode 100644 packages/cli/internal/server/handlers/devices.go create mode 100644 packages/cli/internal/server/handlers/interfaces.go create mode 100644 packages/cli/internal/server/handlers/pages.go create mode 100644 packages/cli/internal/server/handlers/streaming.go create mode 100644 packages/cli/internal/server/handlers/utils.go create mode 100644 packages/cli/internal/server/handlers/webrtc.go create mode 100644 packages/cli/internal/server/router/adb.go create mode 100644 packages/cli/internal/server/router/api.go create mode 100644 packages/cli/internal/server/router/assets.go create mode 100644 packages/cli/internal/server/router/pages.go create mode 100644 packages/cli/internal/server/router/path_utils.go create mode 100644 packages/cli/internal/server/router/router.go create mode 100644 packages/cli/internal/server/router/streaming.go delete mode 100644 packages/cli/internal/server/static/live-view.html delete mode 100644 packages/cli/internal/server/static_handlers.go create mode 100644 packages/cli/internal/util/random.go create mode 100644 packages/live-view/src/lib/h264-client.ts create mode 100644 packages/live-view/src/lib/mse-client.ts diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index 882debad..a2c275f0 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -7,4 +7,5 @@ gbox-windows-* gbox gbox-test -assets/scrcpy-server*.jar \ No newline at end of file +assets/scrcpy-server*.jar +internal/server/static/live-view/ \ No newline at end of file diff --git a/packages/cli/Makefile b/packages/cli/Makefile index 206f4db2..65e384bd 100644 --- a/packages/cli/Makefile +++ b/packages/cli/Makefile @@ -59,9 +59,16 @@ clean: ## Clean the build directory # Build dependencies (live-view and scrcpy-server) build-deps: build-live-view download-scrcpy-server ## Build all dependencies -# Build live-view static files +# Build live-view static files and copy to CLI static directory build-live-view: ## Build live-view static files + @echo "Building live-view static files..." @$(MAKE) -C ../live-view build + @echo "Cleaning old live-view static files..." + @rm -rf internal/server/static/live-view + @echo "Copying live-view static files to CLI..." + @mkdir -p internal/server/static/live-view + @cp -r ../live-view/static/* internal/server/static/live-view/ + @echo "✅ Live-view static files ready for embedding" # Download scrcpy-server.jar download-scrcpy-server: ## Download scrcpy-server.jar diff --git a/packages/cli/cmd/root.go b/packages/cli/cmd/root.go index f9b88655..b69b67c0 100644 --- a/packages/cli/cmd/root.go +++ b/packages/cli/cmd/root.go @@ -17,7 +17,7 @@ var ( aliasMap = map[string]string{} scriptDir string - + // Global verbose flag verbose bool diff --git a/packages/cli/go.mod b/packages/cli/go.mod index a9480933..878059db 100644 --- a/packages/cli/go.mod +++ b/packages/cli/go.mod @@ -32,6 +32,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pion/datachannel v1.5.10 // indirect diff --git a/packages/cli/go.sum b/packages/cli/go.sum index 093b8e54..28b6d7ea 100644 --- a/packages/cli/go.sum +++ b/packages/cli/go.sum @@ -19,6 +19,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/packages/cli/internal/device_connect/api/handlers.go b/packages/cli/internal/device_connect/api/handlers.go index 1a987f73..5a130294 100644 --- a/packages/cli/internal/device_connect/api/handlers.go +++ b/packages/cli/internal/device_connect/api/handlers.go @@ -67,7 +67,7 @@ func (s *Server) handleDeviceAction(w http.ResponseWriter, r *http.Request) { // handleDeviceConnect handles POST /api/devices/{id}/connect func (s *Server) handleDeviceConnect(w http.ResponseWriter, r *http.Request, deviceID string) { // Create WebRTC bridge for the device - bridge, err := s.webrtcManager.CreateBridge(deviceID) + bridge, err := s.bridgeManager.CreateBridge(deviceID) if err != nil { log.Printf("Failed to create bridge for device %s: %v", deviceID, err) respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ @@ -91,7 +91,7 @@ func (s *Server) handleDeviceConnect(w http.ResponseWriter, r *http.Request, dev // handleDeviceDisconnect handles DELETE /api/devices/{id}/disconnect func (s *Server) handleDeviceDisconnect(w http.ResponseWriter, r *http.Request, deviceID string) { // Remove WebRTC bridge - s.webrtcManager.RemoveBridge(deviceID) + s.bridgeManager.RemoveBridge(deviceID) // Mark device as unregistered s.deviceManager.UnregisterDevice(deviceID) @@ -122,7 +122,7 @@ func (s *Server) handleRegisterDevice(w http.ResponseWriter, r *http.Request) { return } - bridge, err := s.webrtcManager.CreateBridge(req.DeviceID) + bridge, err := s.bridgeManager.CreateBridge(req.DeviceID) if err != nil { log.Printf("Failed to create bridge for device %s: %v", req.DeviceID, err) respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ @@ -161,7 +161,7 @@ func (s *Server) handleUnregisterDevice(w http.ResponseWriter, r *http.Request) return } - s.webrtcManager.RemoveBridge(req.DeviceID) + s.bridgeManager.RemoveBridge(req.DeviceID) s.deviceManager.UnregisterDevice(req.DeviceID) log.Printf("Successfully unregistered device %s", req.DeviceID) diff --git a/packages/cli/internal/device_connect/api/server.go b/packages/cli/internal/device_connect/api/server.go index 2b728f26..4e3d191b 100644 --- a/packages/cli/internal/device_connect/api/server.go +++ b/packages/cli/internal/device_connect/api/server.go @@ -10,7 +10,7 @@ import ( "time" "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/webrtc" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/webrtc" ) // Server handles HTTP API and WebSocket connections @@ -18,22 +18,22 @@ type Server struct { port int server *http.Server deviceManager *device.Manager - webrtcManager *webrtc.Manager + bridgeManager *webrtc.Manager isRunning bool } // NewServer creates a new API server func NewServer(port int) *Server { deviceManager := device.NewManager() - - // Get ADB path for WebRTC manager + + // Get ADB path for bridge manager adbPath := "adb" - webrtcManager := webrtc.NewManager(adbPath) - + bridgeManager := webrtc.NewManager(adbPath) + return &Server{ port: port, deviceManager: deviceManager, - webrtcManager: webrtcManager, + bridgeManager: bridgeManager, } } @@ -45,16 +45,16 @@ func (s *Server) Start() error { // Setup routes mux := http.NewServeMux() - + // API routes mux.HandleFunc("/api/devices", s.handleDevices) mux.HandleFunc("/api/devices/", s.handleDeviceAction) mux.HandleFunc("/api/register-device", s.handleRegisterDevice) mux.HandleFunc("/api/unregister-device", s.handleUnregisterDevice) - + // WebSocket route mux.HandleFunc("/ws", s.handleWebSocket) - + // Static files staticPath := s.findLiveViewStaticPath() if staticPath != "" { @@ -80,7 +80,7 @@ func (s *Server) Start() error { // Start server log.Printf("Starting API server on port %d", s.port) s.isRunning = true - + go func() { if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Printf("Server error: %v", err) @@ -90,7 +90,7 @@ func (s *Server) Start() error { // Wait for server to start time.Sleep(100 * time.Millisecond) - + // Test if server is accessible resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/devices", s.port)) if err != nil { @@ -110,12 +110,12 @@ func (s *Server) Stop() error { } log.Println("Stopping API server...") - - // Close WebRTC manager - if s.webrtcManager != nil { - s.webrtcManager.Close() + + // Close bridge manager + if s.bridgeManager != nil { + s.bridgeManager.Close() } - + // Shutdown HTTP server if s.server != nil { if err := s.server.Close(); err != nil { @@ -125,7 +125,7 @@ func (s *Server) Stop() error { s.isRunning = false log.Println("API server stopped") - + return nil } @@ -169,9 +169,9 @@ func (s *Server) findLiveViewStaticPath() string { func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) - + // Use json encoder to write response if err := json.NewEncoder(w).Encode(data); err != nil { log.Printf("Failed to encode JSON response: %v", err) } -} \ No newline at end of file +} diff --git a/packages/cli/internal/device_connect/api/websocket.go b/packages/cli/internal/device_connect/api/websocket.go index 97766b9a..e49a491b 100644 --- a/packages/cli/internal/device_connect/api/websocket.go +++ b/packages/cli/internal/device_connect/api/websocket.go @@ -70,10 +70,10 @@ func (s *Server) handleWebSocketConnect(conn *websocket.Conn, msg map[string]int return } - bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + bridge, exists := s.bridgeManager.GetBridge(deviceSerial) if !exists { var err error - bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + bridge, err = s.bridgeManager.CreateBridge(deviceSerial) if err != nil { log.Printf("Failed to create bridge: %v", err) conn.WriteJSON(map[string]interface{}{ @@ -110,11 +110,11 @@ func (s *Server) handleWebSocketOffer(conn *websocket.Conn, msg map[string]inter } // Get or create bridge for the device - bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + bridge, exists := s.bridgeManager.GetBridge(deviceSerial) if !exists { log.Printf("Bridge not found for device %s, creating new bridge", deviceSerial) var err error - bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + bridge, err = s.bridgeManager.CreateBridge(deviceSerial) if err != nil { log.Printf("Failed to create bridge: %v", err) conn.WriteJSON(map[string]interface{}{ @@ -134,11 +134,11 @@ func (s *Server) handleWebSocketOffer(conn *websocket.Conn, msg map[string]inter // Only recreate bridge if connection is truly closed or failed if connState == webrtc.PeerConnectionStateClosed || connState == webrtc.PeerConnectionStateFailed { log.Printf("WebRTC connection is %s for device %s, recreating bridge", connState, deviceSerial) - s.webrtcManager.RemoveBridge(deviceSerial) + s.bridgeManager.RemoveBridge(deviceSerial) // Create new bridge var err error - bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + bridge, err = s.bridgeManager.CreateBridge(deviceSerial) if err != nil { log.Printf("Failed to recreate bridge: %v", err) conn.WriteJSON(map[string]interface{}{ @@ -150,10 +150,10 @@ func (s *Server) handleWebSocketOffer(conn *websocket.Conn, msg map[string]inter } else if signalingState == webrtc.SignalingStateClosed { // Only recreate if signaling is closed but connection is still active log.Printf("Signaling state is closed for device %s, recreating bridge", deviceSerial) - s.webrtcManager.RemoveBridge(deviceSerial) + s.bridgeManager.RemoveBridge(deviceSerial) var err error - bridge, err = s.webrtcManager.CreateBridge(deviceSerial) + bridge, err = s.bridgeManager.CreateBridge(deviceSerial) if err != nil { log.Printf("Failed to recreate bridge: %v", err) conn.WriteJSON(map[string]interface{}{ @@ -237,7 +237,7 @@ func (s *Server) handleWebSocketICECandidate(conn *websocket.Conn, msg map[strin return } - bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + bridge, exists := s.bridgeManager.GetBridge(deviceSerial) if !exists { return } @@ -267,7 +267,7 @@ func (s *Server) handleWebSocketDisconnect(conn *websocket.Conn, msg map[string] return } - s.webrtcManager.RemoveBridge(deviceSerial) + s.bridgeManager.RemoveBridge(deviceSerial) conn.WriteJSON(map[string]interface{}{ "type": "disconnected", @@ -281,7 +281,7 @@ func (s *Server) handleWebSocketTouch(conn *websocket.Conn, msg map[string]inter return } - bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + bridge, exists := s.bridgeManager.GetBridge(deviceSerial) if !exists { log.Printf("Bridge not found for device %s", deviceSerial) return @@ -297,7 +297,7 @@ func (s *Server) handleWebSocketKey(conn *websocket.Conn, msg map[string]interfa return } - bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + bridge, exists := s.bridgeManager.GetBridge(deviceSerial) if !exists { log.Printf("Bridge not found for device %s", deviceSerial) return @@ -313,7 +313,7 @@ func (s *Server) handleWebSocketScroll(conn *websocket.Conn, msg map[string]inte return } - bridge, exists := s.webrtcManager.GetBridge(deviceSerial) + bridge, exists := s.bridgeManager.GetBridge(deviceSerial) if !exists { log.Printf("Bridge not found for device %s", deviceSerial) return diff --git a/packages/cli/internal/device_connect/core/sample.go b/packages/cli/internal/device_connect/core/sample.go new file mode 100644 index 00000000..de4da68b --- /dev/null +++ b/packages/cli/internal/device_connect/core/sample.go @@ -0,0 +1,20 @@ +package core + +// VideoSample represents a single video frame/sample. +type VideoSample struct { + Data []byte // H.264 NAL unit data + IsKey bool // Whether this is a keyframe (IDR) + PTS int64 // Presentation timestamp +} + +// AudioSample represents a single audio frame/sample. +type AudioSample struct { + Data []byte // Audio data (e.g., Opus) + PTS int64 // Presentation timestamp +} + +// ControlMessage represents a control command or event. +type ControlMessage struct { + Type int32 // Message type + Data []byte // Message payload +} diff --git a/packages/cli/internal/device_connect/core/source.go b/packages/cli/internal/device_connect/core/source.go new file mode 100644 index 00000000..eea453d9 --- /dev/null +++ b/packages/cli/internal/device_connect/core/source.go @@ -0,0 +1,54 @@ +package core + +import ( + "context" + "io" +) + +// Source defines the interface for device video/audio/control sources. +type Source interface { + // Start begins the source operation + Start(ctx context.Context, deviceSerial string) error + + // Stop stops the source operation + Stop() error + + // SubscribeVideo returns a channel for video samples + SubscribeVideo(subscriberID string, bufferSize int) <-chan VideoSample + + // UnsubscribeVideo removes a video subscriber + UnsubscribeVideo(subscriberID string) + + // SubscribeAudio returns a channel for audio samples + SubscribeAudio(subscriberID string, bufferSize int) <-chan AudioSample + + // UnsubscribeAudio removes an audio subscriber + UnsubscribeAudio(subscriberID string) + + // SubscribeControl returns a channel for control messages + SubscribeControl(subscriberID string, bufferSize int) <-chan ControlMessage + + // UnsubscribeControl removes a control subscriber + UnsubscribeControl(subscriberID string) + + // SendControl sends a control message to the device + SendControl(msg ControlMessage) error + + // GetSpsPps returns cached SPS/PPS data for H.264 streams + GetSpsPps() []byte + + // GetConnectionInfo returns device connection information + GetConnectionInfo() (deviceSerial string, videoWidth, videoHeight int) +} + +// StreamWriter defines the interface for writing stream data. +type StreamWriter interface { + io.Writer + io.Closer +} + +// StreamReader defines the interface for reading stream data. +type StreamReader interface { + io.Reader + io.Closer +} diff --git a/packages/cli/internal/device_connect/device/connection.go b/packages/cli/internal/device_connect/device/connection.go index 6d4589fc..63802f63 100644 --- a/packages/cli/internal/device_connect/device/connection.go +++ b/packages/cli/internal/device_connect/device/connection.go @@ -8,23 +8,32 @@ import ( "os/exec" "path/filepath" "time" + + "github.com/babelcloud/gbox/packages/cli/internal/util" ) // Note: assets will be embedded at build time using a different approach // ScrcpyConnection handles the actual scrcpy server connection type ScrcpyConnection struct { - deviceSerial string - scid uint32 - adbPath string - serverPath string - conn net.Conn - Listener net.Listener // Made public to match scrcpy-proxy - serverCmd *exec.Cmd + deviceSerial string + scid uint32 + adbPath string + serverPath string + conn net.Conn + Listener net.Listener // Made public to match scrcpy-proxy + serverCmd *exec.Cmd + videoEncoder string // Video encoder preference + streamingMode string // Streaming mode (h264, webrtc, mse) } // NewScrcpyConnection creates a new scrcpy connection handler func NewScrcpyConnection(deviceSerial string, scid uint32) *ScrcpyConnection { + return NewScrcpyConnectionWithMode(deviceSerial, scid, "webrtc") // Default mode +} + +// NewScrcpyConnectionWithMode creates a new scrcpy connection handler with specific streaming mode +func NewScrcpyConnectionWithMode(deviceSerial string, scid uint32, streamingMode string) *ScrcpyConnection { // Find adb path adbPath, err := exec.LookPath("adb") if err != nil { @@ -38,11 +47,32 @@ func NewScrcpyConnection(deviceSerial string, scid uint32) *ScrcpyConnection { serverPath = "/data/local/tmp/scrcpy-server.jar" } + // Select optimal encoder based on streaming mode + videoEncoder := selectVideoEncoder(streamingMode) + return &ScrcpyConnection{ - deviceSerial: deviceSerial, - scid: scid, - adbPath: adbPath, - serverPath: serverPath, + deviceSerial: deviceSerial, + scid: scid, + adbPath: adbPath, + serverPath: serverPath, + videoEncoder: videoEncoder, + streamingMode: streamingMode, + } +} + +// selectVideoEncoder chooses the optimal video encoder based on streaming mode +func selectVideoEncoder(streamingMode string) string { + switch streamingMode { + case "h264": + // H.264 WebCodecs mode: Use software encoder for maximum compatibility + // OMX.google.h264.encoder is the most reliable software encoder + return "OMX.google.h264.encoder" + case "webrtc", "mse": + // WebRTC and MSE modes: use hardware encoder for better performance + return "c2.qti.avc.encoder" + default: + // Default: use hardware encoder + return "c2.qti.avc.encoder" } } @@ -61,11 +91,31 @@ func (sc *ScrcpyConnection) Connect() (net.Conn, error) { } // 3. Start listener for scrcpy server connection - listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", sc.scid)) - if err != nil { - return nil, fmt.Errorf("failed to start listener on port %d: %w", sc.scid, err) + // Try to find an available port starting from scid + port := sc.scid + maxAttempts := 100 // Try up to 100 different ports + var listener net.Listener + var err error + + for i := 0; i < maxAttempts; i++ { + listener, err = net.Listen("tcp", fmt.Sprintf("localhost:%d", port)) + if err == nil { + // Port is available, update scid to match the actual port used + if port != sc.scid { + log.Printf("Port %d was busy, using port %d instead", sc.scid, port) + sc.scid = port + } + sc.Listener = listener + break + } + + // Port is busy, try next one + port++ + } + + if sc.Listener == nil { + return nil, fmt.Errorf("failed to find available port after %d attempts, starting from port %d", maxAttempts, sc.scid) } - sc.Listener = listener // 4. Start scrcpy server on device if err := sc.startScrcpyServer(); err != nil { @@ -152,23 +202,25 @@ func (sc *ScrcpyConnection) startScrcpyServer() error { // Build scrcpy server command scidHex := fmt.Sprintf("%08x", sc.scid) - cmd := exec.Command(sc.adbPath, "-s", sc.deviceSerial, "shell", + // Build command arguments with optimized settings for WebCodecs + args := []string{ + "-s", sc.deviceSerial, "shell", "CLASSPATH=/data/local/tmp/scrcpy-server.jar", "app_process", "/", "com.genymobile.scrcpy.Server", "3.3.1", // Server version - must match the downloaded jar fmt.Sprintf("scid=%s", scidHex), "video=true", - "audio=true", + "audio=true", // Re-enable audio "control=true", "cleanup=true", - "log_level=verbose", // Enable verbose logging to debug scroll issues - "video_codec_options=i-frame-interval=1", // Force keyframe every 1 second to prevent video freezing - "video_bit_rate=12000000", // 12 Mbps for better quality - "max_fps=60", // 60 FPS for smoother video - "video_encoder=OMX.qcom.video.encoder.avc", // Use Qualcomm hardware encoder (more compatible) - "audio_codec=opus", // Use Opus for better audio quality - "audio_bit_rate=128000", // 128 kbps audio - ) + "log_level=verbose", // Enable verbose logging to debug scroll issues + "video_codec_options=i-frame-interval=1", + } + + // Use default video encoder for all modes + log.Printf("Using default video encoder for %s mode", sc.streamingMode) + + cmd := exec.Command(sc.adbPath, args...) log.Printf("Starting scrcpy server with command: %s", cmd.String()) @@ -176,8 +228,8 @@ func (sc *ScrcpyConnection) startScrcpyServer() error { sc.serverCmd = cmd // Capture output for debugging - cmd.Stdout = &logWriter{prefix: "[scrcpy-out]"} - cmd.Stderr = &logWriter{prefix: "[scrcpy-err]"} + cmd.Stdout = util.NewPrefixLogWriter("[scrcpy-out]") + cmd.Stderr = util.NewPrefixLogWriter("[scrcpy-err]") if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start scrcpy server: %w", err) @@ -255,13 +307,3 @@ func findScrcpyServerJar() string { } // Note: embedded server extraction removed - using external files only - -// logWriter implements io.Writer for logging -type logWriter struct { - prefix string -} - -func (w *logWriter) Write(p []byte) (n int, err error) { - log.Printf("%s %s", w.prefix, string(p)) - return len(p), nil -} diff --git a/packages/cli/internal/device_connect/pipeline/broadcaster.go b/packages/cli/internal/device_connect/pipeline/broadcaster.go new file mode 100644 index 00000000..1fd09cf9 --- /dev/null +++ b/packages/cli/internal/device_connect/pipeline/broadcaster.go @@ -0,0 +1,264 @@ +package pipeline + +import ( + "bytes" + "io" + "sync" + + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// Broadcaster provides a generic pub/sub mechanism that can cache +// initialization segments and distribute data to multiple subscribers. +type Broadcaster struct { + mu sync.RWMutex + subscribers map[string]chan<- []byte + initSegment []byte // Cached initialization segment (e.g., fMP4 header) + hasInit bool + closed bool +} + +// NewBroadcaster creates a new broadcaster instance. +func NewBroadcaster() *Broadcaster { + return &Broadcaster{ + subscribers: make(map[string]chan<- []byte), + } +} + +// SetInitSegment caches the initialization segment that will be sent +// immediately to new subscribers. +func (b *Broadcaster) SetInitSegment(data []byte) { + b.mu.Lock() + defer b.mu.Unlock() + + b.initSegment = make([]byte, len(data)) + copy(b.initSegment, data) + b.hasInit = true + + util.GetLogger().Info("Broadcaster init segment cached", "size", len(data)) +} + +// Subscribe adds a new subscriber with the given ID and returns a channel +// that will receive broadcasted data. If an init segment is cached, +// it will be sent immediately. +func (b *Broadcaster) Subscribe(subscriberID string, bufferSize int) <-chan []byte { + b.mu.Lock() + defer b.mu.Unlock() + + if b.closed { + // Return a closed channel for closed broadcaster + ch := make(chan []byte) + close(ch) + return ch + } + + ch := make(chan []byte, bufferSize) + b.subscribers[subscriberID] = ch + + // Send cached init segment immediately if available + if b.hasInit && len(b.initSegment) > 0 { + select { + case ch <- b.initSegment: + util.GetLogger().Debug("Init segment sent to new subscriber", "id", subscriberID, "size", len(b.initSegment)) + default: + util.GetLogger().Warn("Failed to send init segment to new subscriber (channel full)", "id", subscriberID) + } + } + + util.GetLogger().Info("New subscriber added", "id", subscriberID, "total", len(b.subscribers)) + return ch +} + +// Unsubscribe removes a subscriber and closes its channel. +func (b *Broadcaster) Unsubscribe(subscriberID string) { + b.mu.Lock() + defer b.mu.Unlock() + + if ch, exists := b.subscribers[subscriberID]; exists { + close(ch) + delete(b.subscribers, subscriberID) + util.GetLogger().Info("Subscriber removed", "id", subscriberID, "remaining", len(b.subscribers)) + } +} + +// Broadcast sends data to all current subscribers. If a subscriber's +// channel is full, that subscriber will be dropped. +func (b *Broadcaster) Broadcast(data []byte) { + if len(data) == 0 { + return + } + + b.mu.RLock() + if b.closed { + b.mu.RUnlock() + return + } + + // Create a copy of subscriber map to avoid holding the lock during broadcast + subscribers := make(map[string]chan<- []byte, len(b.subscribers)) + for id, ch := range b.subscribers { + subscribers[id] = ch + } + b.mu.RUnlock() + + // Broadcast to all subscribers + var droppedSubscribers []string + for id, ch := range subscribers { + select { + case ch <- data: + // Successfully sent + default: + // Channel is full, mark for removal + droppedSubscribers = append(droppedSubscribers, id) + util.GetLogger().Warn("Dropping subscriber due to full channel", "id", id) + } + } + + // Remove dropped subscribers + if len(droppedSubscribers) > 0 { + b.mu.Lock() + for _, id := range droppedSubscribers { + if ch, exists := b.subscribers[id]; exists { + close(ch) + delete(b.subscribers, id) + } + } + b.mu.Unlock() + } +} + +// Close shuts down the broadcaster and closes all subscriber channels. +func (b *Broadcaster) Close() { + b.mu.Lock() + defer b.mu.Unlock() + + if b.closed { + return + } + + b.closed = true + for id, ch := range b.subscribers { + close(ch) + util.GetLogger().Debug("Closed subscriber channel", "id", id) + } + b.subscribers = make(map[string]chan<- []byte) + util.GetLogger().Info("Broadcaster closed") +} + +// GetSubscriberCount returns the current number of subscribers. +func (b *Broadcaster) GetSubscriberCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.subscribers) +} + +// StreamToBroadcaster is a helper function that reads from an io.Reader +// and broadcasts the data. This is useful for piping FFmpeg output +// directly to the broadcaster. +func StreamToBroadcaster(reader io.Reader, broadcaster *Broadcaster, bufferSize int) error { + logger := util.GetLogger() + buffer := make([]byte, bufferSize) + + for { + n, err := reader.Read(buffer) + if n > 0 { + // Make a copy of the data before broadcasting + data := make([]byte, n) + copy(data, buffer[:n]) + broadcaster.Broadcast(data) + } + + if err != nil { + if err == io.EOF { + logger.Info("Stream ended normally") + return nil + } + logger.Error("Stream read error", "error", err) + return err + } + } +} + +// ExtractInitSegment attempts to extract the fMP4 initialization segment +// from the beginning of a stream. This looks for the 'ftyp' and 'moov' boxes. +func ExtractInitSegment(data []byte) (initSegment []byte, remaining []byte, found bool) { + if len(data) < 8 { + return nil, data, false + } + + var offset int + var foundFtyp, foundMoov bool + + // Look for ftyp and moov boxes + for offset < len(data)-8 { + if offset+8 > len(data) { + break + } + + // Read box size (big-endian) + size := int(data[offset])<<24 | int(data[offset+1])<<16 | int(data[offset+2])<<8 | int(data[offset+3]) + if size < 8 || offset+size > len(data) { + break + } + + // Read box type + boxType := string(data[offset+4 : offset+8]) + + switch boxType { + case "ftyp": + foundFtyp = true + case "moov": + foundMoov = true + // moov box completes the init segment + initSegmentEnd := offset + size + return data[:initSegmentEnd], data[initSegmentEnd:], true + case "moof": + // moof indicates start of media segments + if foundFtyp && foundMoov { + return data[:offset], data[offset:], true + } + // If we hit moof without complete init, something's wrong + return nil, data, false + } + + offset += size + } + + // Haven't found complete init segment yet + return nil, data, false +} + +// DetectInitSegmentFromStream reads from a stream until it can extract +// the initialization segment, then returns both the init segment and +// a reader for the remaining data. +func DetectInitSegmentFromStream(reader io.Reader) (initSegment []byte, remainingReader io.Reader, err error) { + var buffer bytes.Buffer + tempBuf := make([]byte, 4096) + + for { + n, readErr := reader.Read(tempBuf) + if n > 0 { + buffer.Write(tempBuf[:n]) + + // Try to extract init segment + if init, remaining, found := ExtractInitSegment(buffer.Bytes()); found { + // Create a reader that contains the remaining data plus future reads + remainingReader = io.MultiReader(bytes.NewReader(remaining), reader) + return init, remainingReader, nil + } + } + + if readErr != nil { + if readErr == io.EOF && buffer.Len() > 0 { + // Return whatever we have as remaining data + return nil, bytes.NewReader(buffer.Bytes()), nil + } + return nil, nil, readErr + } + + // Prevent unbounded buffer growth + if buffer.Len() > 1024*1024 { // 1MB limit + return nil, bytes.NewReader(buffer.Bytes()), nil + } + } +} diff --git a/packages/cli/internal/device_connect/pipeline/pipeline.go b/packages/cli/internal/device_connect/pipeline/pipeline.go new file mode 100644 index 00000000..902f83da --- /dev/null +++ b/packages/cli/internal/device_connect/pipeline/pipeline.go @@ -0,0 +1,120 @@ +package pipeline + +import ( + "sync" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// Pipeline manages video/audio sample distribution. +type Pipeline struct { + mu sync.RWMutex + spsPps []byte + + // Video subscribers + videoSubs map[string]chan core.VideoSample + + // Audio subscribers + audioSubs map[string]chan core.AudioSample +} + +// NewPipeline creates a new pipeline. +func NewPipeline() *Pipeline { + return &Pipeline{ + videoSubs: make(map[string]chan core.VideoSample), + audioSubs: make(map[string]chan core.AudioSample), + } +} + +// CacheSpsPps caches SPS/PPS data for H.264 streams. +func (p *Pipeline) CacheSpsPps(spsPps []byte) { + p.mu.Lock() + defer p.mu.Unlock() + p.spsPps = spsPps + util.GetLogger().Debug("Pipeline SPS/PPS cached", "size", len(spsPps)) +} + +// GetSpsPps returns cached SPS/PPS data. +func (p *Pipeline) GetSpsPps() []byte { + p.mu.RLock() + defer p.mu.RUnlock() + return p.spsPps +} + +// SubscribeVideo adds a video subscriber. +func (p *Pipeline) SubscribeVideo(id string, bufferSize int) <-chan core.VideoSample { + p.mu.Lock() + defer p.mu.Unlock() + + ch := make(chan core.VideoSample, bufferSize) + p.videoSubs[id] = ch + util.GetLogger().Debug("Video subscriber added", "id", id, "total", len(p.videoSubs)) + return ch +} + +// UnsubscribeVideo removes a video subscriber. +func (p *Pipeline) UnsubscribeVideo(id string) { + p.mu.Lock() + defer p.mu.Unlock() + + if ch, exists := p.videoSubs[id]; exists { + close(ch) + delete(p.videoSubs, id) + util.GetLogger().Info("Video subscriber removed", "id", id, "total", len(p.videoSubs)) + } +} + +// PublishVideo publishes a video sample to all subscribers. +func (p *Pipeline) PublishVideo(sample core.VideoSample) { + p.mu.RLock() + defer p.mu.RUnlock() + + for id, ch := range p.videoSubs { + select { + case ch <- sample: + // Sample sent successfully + default: + // Channel is full, skip + util.GetLogger().Warn("Video channel full, dropping sample", "subscriber", id) + } + } +} + +// SubscribeAudio adds an audio subscriber. +func (p *Pipeline) SubscribeAudio(id string, bufferSize int) <-chan core.AudioSample { + p.mu.Lock() + defer p.mu.Unlock() + + ch := make(chan core.AudioSample, bufferSize) + p.audioSubs[id] = ch + util.GetLogger().Debug("Audio subscriber added", "id", id, "total", len(p.audioSubs)) + return ch +} + +// UnsubscribeAudio removes an audio subscriber. +func (p *Pipeline) UnsubscribeAudio(id string) { + p.mu.Lock() + defer p.mu.Unlock() + + if ch, exists := p.audioSubs[id]; exists { + close(ch) + delete(p.audioSubs, id) + util.GetLogger().Info("Audio subscriber removed", "id", id, "total", len(p.audioSubs)) + } +} + +// PublishAudio publishes an audio sample to all subscribers. +func (p *Pipeline) PublishAudio(sample core.AudioSample) { + p.mu.RLock() + defer p.mu.RUnlock() + + for id, ch := range p.audioSubs { + select { + case ch <- sample: + default: + // Channel is full, skip + util.GetLogger().Debug("Audio channel full, dropping sample", "subscriber", id) + } + } +} diff --git a/packages/cli/internal/device_connect/protocol/control.go b/packages/cli/internal/device_connect/protocol/control.go index 00e68d23..e2d1b35c 100644 --- a/packages/cli/internal/device_connect/protocol/control.go +++ b/packages/cli/internal/device_connect/protocol/control.go @@ -2,7 +2,6 @@ package protocol import ( "encoding/binary" - "fmt" ) // Control message type aliases for compatibility @@ -211,29 +210,8 @@ func EncodeScrollEvent(event ScrollEvent, screenWidth, screenHeight int) []byte // Buttons (none) binary.BigEndian.PutUint32(buf[16:20], 0) - // Debug logging - fmt.Printf("EncodeScrollEvent: input=(%.2f, %.2f, %.2f, %.2f), screen=%dx%d\n", - event.X, event.Y, event.HScroll, event.VScroll, screenWidth, screenHeight) - fmt.Printf("EncodeScrollEvent: calculated position=(%d, %d), screen_size=(%d, %d)\n", - screenX, screenY, screenWidth, screenHeight) - fmt.Printf("EncodeScrollEvent: normalized=(%.2f, %.2f), int32=(%d, %d), int16=(%d, %d)\n", - hScrollNorm, vScrollNorm, hScrollInt32, vScrollInt32, hScroll, vScroll) - fmt.Printf("EncodeScrollEvent: uint16_values=(%d, %d), encoded buffer: %v\n", - hScrollUint16, vScrollUint16, buf) - - // Detailed buffer analysis - fmt.Printf("EncodeScrollEvent: buffer breakdown:\n") - fmt.Printf(" [0:4] = %v (x=%d)\n", buf[0:4], binary.BigEndian.Uint32(buf[0:4])) - fmt.Printf(" [4:8] = %v (y=%d)\n", buf[4:8], binary.BigEndian.Uint32(buf[4:8])) - fmt.Printf(" [8:10] = %v (width=%d)\n", buf[8:10], binary.BigEndian.Uint16(buf[8:10])) - fmt.Printf(" [10:12] = %v (height=%d)\n", buf[10:12], binary.BigEndian.Uint16(buf[10:12])) - fmt.Printf(" [12:14] = %v (hScroll=%d)\n", buf[12:14], binary.BigEndian.Uint16(buf[12:14])) - fmt.Printf(" [14:16] = %v (vScroll=%d)\n", buf[14:16], binary.BigEndian.Uint16(buf[14:16])) - fmt.Printf(" [16:20] = %v (buttons=%d)\n", buf[16:20], binary.BigEndian.Uint32(buf[16:20])) - - // Compare with scrcpy test case format - fmt.Printf("EncodeScrollEvent: scrcpy test comparison - expecting position=(%d, %d), screen=(%d, %d)\n", - screenX, screenY, screenWidth, screenHeight) + // Debug logging (uncomment for debugging scroll issues) + // log.Printf("Scroll event encoded: x=%d, y=%d, hScroll=%d, vScroll=%d", screenX, screenY, hScroll, vScroll) return buf } diff --git a/packages/cli/internal/device_connect/scrcpy/control.go b/packages/cli/internal/device_connect/scrcpy/control.go new file mode 100644 index 00000000..700faa6c --- /dev/null +++ b/packages/cli/internal/device_connect/scrcpy/control.go @@ -0,0 +1,46 @@ +package scrcpy + +import ( + "io" + "net" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// drainControl starts a non-blocking drain on the control connection to avoid backpressure. +func drainControl(conn net.Conn) { + if conn == nil { + return + } + go func(c net.Conn) { io.Copy(io.Discard, c) }(conn) +} + +// requestKeyframeAsync sends a keyframe reset request when controlConn is available. +// It is safe to call multiple times; if controlConn is nil it will wait briefly +// and retry once to avoid blocking the caller. +func (s *Source) requestKeyframeAsync() { + go func() { + logger := util.GetLogger() + // small grace period if control is about to be set + deadline := time.Now().Add(1500 * time.Millisecond) + for time.Now().Before(deadline) { + s.mu.Lock() + conn := s.controlConn + s.mu.Unlock() + if conn != nil { + // Serialize control message: [type][payload] + buf := []byte{byte(protocol.ControlMsgTypeResetVideo)} + if _, err := conn.Write(buf); err != nil { + logger.Warn("Failed to send keyframe request", "error", err) + } else { + logger.Debug("Keyframe request sent") + } + return + } + time.Sleep(100 * time.Millisecond) + } + logger.Debug("Control not ready; skip keyframe request") + }() +} diff --git a/packages/cli/internal/device_connect/scrcpy/handshake.go b/packages/cli/internal/device_connect/scrcpy/handshake.go new file mode 100644 index 00000000..25136987 --- /dev/null +++ b/packages/cli/internal/device_connect/scrcpy/handshake.go @@ -0,0 +1,19 @@ +package scrcpy + +import ( + "encoding/binary" + "fmt" + "io" +) + +// readVideoHeader reads codec (4 bytes), width (4), height (4) from scrcpy stream. +func readVideoHeader(r io.Reader) (codec uint32, width uint32, height uint32, err error) { + buf := make([]byte, 12) + if _, err = io.ReadFull(r, buf); err != nil { + return 0, 0, 0, fmt.Errorf("readVideoHeader: %w", err) + } + codec = binary.BigEndian.Uint32(buf[0:4]) + width = binary.BigEndian.Uint32(buf[4:8]) + height = binary.BigEndian.Uint32(buf[8:12]) + return +} diff --git a/packages/cli/internal/device_connect/scrcpy/manager.go b/packages/cli/internal/device_connect/scrcpy/manager.go new file mode 100644 index 00000000..65c46e9b --- /dev/null +++ b/packages/cli/internal/device_connect/scrcpy/manager.go @@ -0,0 +1,107 @@ +package scrcpy + +import ( + "context" + "sync" + + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// GlobalManager manages shared scrcpy sources per device +type GlobalManager struct { + mu sync.RWMutex + sources map[string]*Source +} + +var globalManager = &GlobalManager{ + sources: make(map[string]*Source), +} + +// GetOrCreateSource returns an existing source or creates a new one +func GetOrCreateSource(deviceSerial string) *Source { + return GetOrCreateSourceWithMode(deviceSerial, "webrtc") // Default mode +} + +// GetOrCreateSourceWithMode returns an existing source or creates a new one with specific mode +func GetOrCreateSourceWithMode(deviceSerial string, streamingMode string) *Source { + globalManager.mu.Lock() + defer globalManager.mu.Unlock() + + if src, exists := globalManager.sources[deviceSerial]; exists { + // Check if source is still valid (has active cancel function) + src.mu.Lock() + if src.cancel != nil { + // Source is still active, update streaming mode if different + if src.streamingMode != streamingMode { + util.GetLogger().Info("Updating streaming mode", "device", deviceSerial, "from", src.streamingMode, "to", streamingMode) + src.streamingMode = streamingMode + } + src.mu.Unlock() + util.GetLogger().Info("Using existing scrcpy source", "device", deviceSerial, "mode", streamingMode) + return src + } + src.mu.Unlock() + + // Source exists but is not active, remove it and create a new one + util.GetLogger().Info("Removing inactive scrcpy source", "device", deviceSerial) + delete(globalManager.sources, deviceSerial) + } + + util.GetLogger().Info("Creating new scrcpy source", "device", deviceSerial, "mode", streamingMode) + src := NewSourceWithMode(deviceSerial, streamingMode) + globalManager.sources[deviceSerial] = src + return src +} + +// StartSource starts a source if not already started +func StartSource(deviceSerial string, ctx context.Context) (*Source, error) { + return StartSourceWithMode(deviceSerial, ctx, "webrtc") // Default mode +} + +// StartSourceWithMode starts a source with specific streaming mode +func StartSourceWithMode(deviceSerial string, ctx context.Context, streamingMode string) (*Source, error) { + src := GetOrCreateSourceWithMode(deviceSerial, streamingMode) + + // Check if already started + src.mu.Lock() + if src.cancel != nil { + src.mu.Unlock() + util.GetLogger().Info("Scrcpy source already started", "device", deviceSerial) + return src, nil + } + src.mu.Unlock() + + // Start the source + if err := src.Start(ctx, deviceSerial); err != nil { + util.GetLogger().Error("Failed to start scrcpy source", "device", deviceSerial, "error", err) + + // If start failed, clean up the source state + src.mu.Lock() + src.cancel = nil + src.mu.Unlock() + + return nil, err + } + + util.GetLogger().Info("Scrcpy source started successfully", "device", deviceSerial) + return src, nil +} + +// RemoveSource removes a source from the global manager +func RemoveSource(deviceSerial string) { + globalManager.mu.Lock() + defer globalManager.mu.Unlock() + + if src, exists := globalManager.sources[deviceSerial]; exists { + src.Stop() + delete(globalManager.sources, deviceSerial) + util.GetLogger().Info("Removed scrcpy source", "device", deviceSerial) + } +} + +// GetSource returns an existing source if it exists +func GetSource(deviceSerial string) *Source { + globalManager.mu.RLock() + defer globalManager.mu.RUnlock() + return globalManager.sources[deviceSerial] +} diff --git a/packages/cli/internal/device_connect/scrcpy/source.go b/packages/cli/internal/device_connect/scrcpy/source.go new file mode 100644 index 00000000..df33be2a --- /dev/null +++ b/packages/cli/internal/device_connect/scrcpy/source.go @@ -0,0 +1,475 @@ +package scrcpy + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "net" + "strings" + "sync" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/pipeline" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// Source implements the core.Source interface for scrcpy devices +type Source struct { + mu sync.RWMutex + deviceSerial string + pipeline *pipeline.Pipeline + cancel context.CancelFunc + streamingMode string // Streaming mode (h264, webrtc, mse) + + // Connections + audioConn net.Conn + controlConn net.Conn + + // Handshake info + videoWidth int + videoHeight int + spsPps []byte +} + +// NewSource creates a new scrcpy source +func NewSource(deviceSerial string) *Source { + return NewSourceWithMode(deviceSerial, "webrtc") // Default mode +} + +func NewSourceWithMode(deviceSerial string, streamingMode string) *Source { + return &Source{ + deviceSerial: deviceSerial, + pipeline: pipeline.NewPipeline(), + streamingMode: streamingMode, + } +} + +// Start implements core.Source +func (s *Source) Start(ctx context.Context, deviceSerial string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cancel != nil { + return fmt.Errorf("source already started") + } + + ctx, cancel := context.WithCancel(ctx) + s.cancel = cancel + + // Start scrcpy reader in background + go s.runReader(ctx) + + util.GetLogger().Info("Scrcpy source started", "device", deviceSerial) + return nil +} + +// Stop implements core.Source +func (s *Source) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cancel != nil { + s.cancel() + s.cancel = nil + } + + // Close all active connections to ensure clean state + if s.audioConn != nil { + s.audioConn.Close() + s.audioConn = nil + } + if s.controlConn != nil { + s.controlConn.Close() + s.controlConn = nil + } + + util.GetLogger().Info("Scrcpy source stopped", "device", s.deviceSerial) + return nil +} + +// SubscribeVideo implements core.Source +func (s *Source) SubscribeVideo(subscriberID string, bufferSize int) <-chan core.VideoSample { + return s.pipeline.SubscribeVideo(subscriberID, bufferSize) +} + +// UnsubscribeVideo implements core.Source +func (s *Source) UnsubscribeVideo(subscriberID string) { + s.pipeline.UnsubscribeVideo(subscriberID) +} + +// SubscribeAudio implements core.Source +func (s *Source) SubscribeAudio(subscriberID string, bufferSize int) <-chan core.AudioSample { + return s.pipeline.SubscribeAudio(subscriberID, bufferSize) +} + +// UnsubscribeAudio implements core.Source +func (s *Source) UnsubscribeAudio(subscriberID string) { + s.pipeline.UnsubscribeAudio(subscriberID) +} + +// SubscribeControl implements core.Source +func (s *Source) SubscribeControl(subscriberID string, bufferSize int) <-chan core.ControlMessage { + // Control channel not implemented yet + return make(chan core.ControlMessage, bufferSize) +} + +// UnsubscribeControl implements core.Source +func (s *Source) UnsubscribeControl(subscriberID string) { + // Control channel not implemented yet +} + +// SendControl implements core.Source +func (s *Source) SendControl(msg core.ControlMessage) error { + s.mu.RLock() + conn := s.controlConn + s.mu.RUnlock() + + if conn == nil { + // Don't return error for control connection not ready during startup + // This is expected during the initial connection phase + util.GetLogger().Debug("Control connection not ready, ignoring control message", + "device", s.deviceSerial, "msg_type", msg.Type) + return nil + } + + // Serialize control message + data := protocol.SerializeControlMessage(&protocol.ControlMessage{ + Type: uint8(msg.Type), + Data: msg.Data, + }) + + // Send to device + if _, err := conn.Write(data); err != nil { + return fmt.Errorf("failed to send control message: %w", err) + } + + return nil +} + +// GetSpsPps implements core.Source +func (s *Source) GetSpsPps() []byte { + s.mu.RLock() + defer s.mu.RUnlock() + return s.spsPps +} + +// GetConnectionInfo implements core.Source +func (s *Source) GetConnectionInfo() (deviceSerial string, videoWidth, videoHeight int) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.deviceSerial, s.videoWidth, s.videoHeight +} + +// RequestKeyframe requests a keyframe from the device +func (s *Source) RequestKeyframe() { + s.requestKeyframeAsync() +} + +// GetPipeline returns the pipeline for backward compatibility +func (s *Source) GetPipeline() *pipeline.Pipeline { + return s.pipeline +} + +// runReader runs the scrcpy reader in a separate goroutine +func (s *Source) runReader(ctx context.Context) { + logger := util.GetLogger() + logger.Info("Scrcpy reader started", "device", s.deviceSerial) + + // Ensure we clean up the cancel function when this goroutine exits + defer func() { + s.mu.Lock() + s.cancel = nil + s.mu.Unlock() + logger.Info("Scrcpy reader stopped", "device", s.deviceSerial) + }() + + // Create scrcpy connection + scrcpyConn, err := s.createScrcpyConnection() + if err != nil { + logger.Error("Failed to create scrcpy connection", "device", s.deviceSerial, "error", err) + return + } + + // Connect to scrcpy server + conn, err := scrcpyConn.Connect() + if err != nil { + logger.Error("Failed to connect to scrcpy server", "device", s.deviceSerial, "error", err) + return + } + defer conn.Close() + + logger.Info("Connected to scrcpy server", "device", s.deviceSerial) + + // Start listening for additional connections (audio/control) + if scrcpyConn.Listener != nil { + go s.handleStreamConnections(ctx, scrcpyConn.Listener) + } + + // Start reading video stream from the first connection + go s.handleVideoStream(ctx, conn) + + // Wait for context cancellation + <-ctx.Done() +} + +// createScrcpyConnection creates a scrcpy connection for the device +func (s *Source) createScrcpyConnection() (*device.ScrcpyConnection, error) { + // Generate a unique session ID + scid := uint32(10000 + time.Now().UnixNano()%55536) + return device.NewScrcpyConnectionWithMode(s.deviceSerial, scid, s.streamingMode), nil +} + +// handleStreamConnections handles additional scrcpy connections (audio/control) +func (s *Source) handleStreamConnections(ctx context.Context, listener net.Listener) { + logger := util.GetLogger() + + connectionCount := 0 + for { + select { + case <-ctx.Done(): + return + default: + } + + // Set accept timeout to prevent indefinite blocking + if tcpListener, ok := listener.(*net.TCPListener); ok { + tcpListener.SetDeadline(time.Now().Add(1 * time.Second)) + } + + conn, err := listener.Accept() + if err != nil { + // Check if it's a timeout error + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue // Continue trying to accept + } + logger.Error("Failed to accept stream connection", "error", err) + continue + } + + connectionCount++ + logger.Info("New stream connection received", "device", s.deviceSerial, "connection_number", connectionCount) + go s.handleStreamConnection(ctx, conn) + } +} + +// handleStreamConnection handles a single stream connection +func (s *Source) handleStreamConnection(ctx context.Context, conn net.Conn) { + logger := util.GetLogger() + + // scrcpy uses connection order to identify stream types: + // 1st connection: video (handled separately in runReader) + // 2nd connection: audio + // 3rd connection: control + + // Check if this is audio or control based on existing connections + s.mu.Lock() + defer s.mu.Unlock() + + if s.audioConn == nil { + // This is the audio stream (2nd connection) + logger.Debug("Audio stream connected", "device", s.deviceSerial) + s.audioConn = conn + go s.handleAudioStream(ctx, conn) + } else if s.controlConn == nil { + // This is the control stream (3rd connection) + logger.Info("Control stream connected", "device", s.deviceSerial) + s.controlConn = conn + go s.handleControlStream(ctx, conn) + } else { + // Unexpected additional connection + logger.Warn("Unexpected additional connection received", "device", s.deviceSerial) + conn.Close() + } +} + +// handleVideoStream processes the video stream +func (s *Source) handleVideoStream(ctx context.Context, conn net.Conn) { + logger := util.GetLogger() + logger.Debug("Starting video stream processing", "device", s.deviceSerial) + + // Read video metadata and handshake information + if err := s.readVideoMetadata(conn); err != nil { + logger.Error("Failed to read video metadata", "device", s.deviceSerial, "error", err) + return + } + + // Start reading video packets + for { + select { + case <-ctx.Done(): + return + default: + } + + // Read video packet + packet, err := protocol.ReadVideoPacket(conn) + if err != nil { + // Check if context was cancelled while reading + select { + case <-ctx.Done(): + logger.Debug("Video stream context cancelled during read", "device", s.deviceSerial) + return + default: + } + + if err == io.EOF { + logger.Info("Video stream ended", "device", s.deviceSerial) + } else if strings.Contains(err.Error(), "use of closed network connection") { + logger.Debug("Video connection closed", "device", s.deviceSerial) + } else { + logger.Error("Failed to read video packet", "device", s.deviceSerial, "error", err) + } + return + } + + // Create video sample + sample := core.VideoSample{ + Data: packet.Data, + IsKey: packet.IsKeyFrame, + PTS: int64(packet.PTS), + } + + // Cache SPS/PPS if this is a config packet + if packet.IsConfig { + s.mu.Lock() + s.spsPps = append([]byte{}, packet.Data...) + s.mu.Unlock() + s.pipeline.CacheSpsPps(packet.Data) + logger.Debug("SPS/PPS cached", "device", s.deviceSerial, "size", len(packet.Data)) + } + + // Log keyframes for monitoring + if packet.IsKeyFrame { + logger.Debug("Video keyframe received", "device", s.deviceSerial, "size", len(packet.Data)) + } + + // Publish to pipeline + s.pipeline.PublishVideo(sample) + } +} + +// handleAudioStream processes the audio stream +func (s *Source) handleAudioStream(ctx context.Context, conn net.Conn) { + logger := util.GetLogger() + logger.Debug("Starting audio stream processing", "device", s.deviceSerial) + defer func() { + conn.Close() + logger.Info("Audio stream processing stopped", "device", s.deviceSerial) + }() + + // Read audio metadata + if err := s.readAudioMetadata(conn); err != nil { + logger.Error("Failed to read audio metadata", "device", s.deviceSerial, "error", err) + return + } + + // Start reading audio packets + for { + select { + case <-ctx.Done(): + logger.Debug("Audio stream context cancelled", "device", s.deviceSerial) + return + default: + } + + // Read audio packet with timeout to prevent blocking on closed connections + packet, err := protocol.ReadAudioPacket(conn) + if err != nil { + // Check if context was cancelled while reading + select { + case <-ctx.Done(): + logger.Debug("Audio stream context cancelled during read", "device", s.deviceSerial) + return + default: + } + + if err == io.EOF { + logger.Info("Audio stream ended", "device", s.deviceSerial) + } else if strings.Contains(err.Error(), "use of closed network connection") { + logger.Debug("Audio connection closed", "device", s.deviceSerial) + } else { + logger.Error("Failed to read audio packet", "device", s.deviceSerial, "error", err) + } + return + } + + // Create audio sample + sample := core.AudioSample{ + Data: packet.Data, + PTS: int64(packet.PTS), + } + + // Audio packet processed (debug logging removed to reduce noise) + + // Publish to pipeline + s.pipeline.PublishAudio(sample) + } +} + +// handleControlStream processes the control stream +func (s *Source) handleControlStream(ctx context.Context, conn net.Conn) { + logger := util.GetLogger() + logger.Debug("Starting control stream processing", "device", s.deviceSerial) + defer conn.Close() + + // Start drain to prevent blocking + drainControl(conn) + + // Keep connection alive but don't process control messages for now + <-ctx.Done() + logger.Info("Control stream ended", "device", s.deviceSerial) +} + +// readVideoMetadata reads video metadata from the connection +func (s *Source) readVideoMetadata(conn net.Conn) error { + logger := util.GetLogger() + + // Read device name + deviceName := make([]byte, 64) + if _, err := io.ReadFull(conn, deviceName); err != nil { + return fmt.Errorf("failed to read device name: %w", err) + } + // Clean device name by removing null characters and trimming + cleanDeviceName := strings.TrimRight(string(deviceName), "\x00") + logger.Info("Device name read", "device", s.deviceSerial, "name", cleanDeviceName) + + // Read video metadata (codecId, width, height) + metaBuf := make([]byte, 12) + if _, err := io.ReadFull(conn, metaBuf); err != nil { + return fmt.Errorf("failed to read video metadata: %w", err) + } + + codecID := binary.BigEndian.Uint32(metaBuf[0:4]) + width := int(binary.BigEndian.Uint32(metaBuf[4:8])) + height := int(binary.BigEndian.Uint32(metaBuf[8:12])) + + s.mu.Lock() + s.videoWidth = width + s.videoHeight = height + s.mu.Unlock() + + logger.Info("Video metadata read", "device", s.deviceSerial, + "codec_id", codecID, "width", width, "height", height) + + return nil +} + +// readAudioMetadata reads audio metadata from the connection +func (s *Source) readAudioMetadata(conn net.Conn) error { + logger := util.GetLogger() + + // Read audio metadata (codecId) + metaBuf := make([]byte, 4) + if _, err := io.ReadFull(conn, metaBuf); err != nil { + return fmt.Errorf("failed to read audio metadata: %w", err) + } + + codecID := binary.BigEndian.Uint32(metaBuf) + logger.Info("Audio metadata read", "device", s.deviceSerial, "codec_id", codecID) + + return nil +} diff --git a/packages/cli/internal/device_connect/stream/audio.go b/packages/cli/internal/device_connect/stream/audio.go deleted file mode 100644 index 81d1883f..00000000 --- a/packages/cli/internal/device_connect/stream/audio.go +++ /dev/null @@ -1,99 +0,0 @@ -package stream - -import ( - "encoding/binary" - "io" - "log" - "time" - - "github.com/pion/webrtc/v4" - "github.com/pion/webrtc/v4/pkg/media" -) - -// AudioHandler handles audio stream processing -type AudioHandler struct { - track *webrtc.TrackLocalStaticSample - sampleRate uint32 - channels uint16 -} - -// NewAudioHandler creates a new audio stream handler -func NewAudioHandler(track *webrtc.TrackLocalStaticSample) *AudioHandler { - return &AudioHandler{ - track: track, - sampleRate: 48000, // Default Opus sample rate - channels: 2, // Stereo - } -} - -// HandleStream processes audio stream from device -func (vh *AudioHandler) HandleStream(reader io.Reader) error { - // Read audio metadata - metaBuf := make([]byte, 4) // codecId - if _, err := io.ReadFull(reader, metaBuf); err != nil { - return err - } - - codecID := binary.BigEndian.Uint32(metaBuf) - log.Printf("Audio stream started - Codec: %d", codecID) - - // Start streaming audio packets - return vh.streamPackets(reader) -} - -// streamPackets reads and processes audio packets -func (vh *AudioHandler) streamPackets(reader io.Reader) error { - for { - // Read packet header (8 bytes: PTS) - header := make([]byte, 8) - if _, err := io.ReadFull(reader, header); err != nil { - if err == io.EOF { - log.Println("Audio stream ended") - return nil - } - return err - } - - pts := binary.BigEndian.Uint64(header) - - // Read packet size (4 bytes) - sizeBuf := make([]byte, 4) - if _, err := io.ReadFull(reader, sizeBuf); err != nil { - return err - } - - packetSize := binary.BigEndian.Uint32(sizeBuf) - if packetSize > 1024*1024 { // 1MB max - log.Printf("Warning: Large audio packet: %d bytes", packetSize) - continue - } - - // Read packet data - packetData := make([]byte, packetSize) - if _, err := io.ReadFull(reader, packetData); err != nil { - return err - } - - // Send audio packet - if err := vh.sendAudioPacket(packetData, pts); err != nil { - log.Printf("Error sending audio packet: %v", err) - } - } -} - -// sendAudioPacket sends audio data via WebRTC -func (vh *AudioHandler) sendAudioPacket(data []byte, pts uint64) error { - if vh.track == nil { - return nil - } - - // Calculate duration based on Opus frame size (20ms frames typical) - duration := 20 * time.Millisecond - - sample := media.Sample{ - Data: data, - Duration: duration, - } - - return vh.track.WriteSample(sample) -} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/stream/control.go b/packages/cli/internal/device_connect/stream/control.go deleted file mode 100644 index 75796f5e..00000000 --- a/packages/cli/internal/device_connect/stream/control.go +++ /dev/null @@ -1,575 +0,0 @@ -package stream - -import ( - "encoding/json" - "fmt" - "io" - "net" - "time" - - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" - "github.com/babelcloud/gbox/packages/cli/internal/util" - "github.com/pion/webrtc/v4" -) - -// ControlHandler handles control stream and messages -type ControlHandler struct { - conn net.Conn - dataChannel *webrtc.DataChannel - screenWidth int - screenHeight int -} - -// NewControlHandler creates a new control stream handler -func NewControlHandler(conn net.Conn, dataChannel *webrtc.DataChannel, screenWidth, screenHeight int) *ControlHandler { - logger := util.GetLogger() - logger.Debug("Creating control handler", - "conn_available", conn != nil, - "datachannel_available", dataChannel != nil, - "screen_width", screenWidth, - "screen_height", screenHeight) - return &ControlHandler{ - conn: conn, - dataChannel: dataChannel, - screenWidth: screenWidth, - screenHeight: screenHeight, - } -} - -// HandleIncomingMessages handles control messages from WebRTC -func (ch *ControlHandler) HandleIncomingMessages() { - logger := util.GetLogger() - logger.Debug("HandleIncomingMessages called") - - if ch.dataChannel == nil { - logger.Error("DataChannel is nil, cannot set up message handling") - return - } - - logger.Debug("Setting up DataChannel message handling", - "state", ch.dataChannel.ReadyState()) - - ch.dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) { - // Parse control message first to determine if it's a ping - var message map[string]interface{} - if err := json.Unmarshal(msg.Data, &message); err != nil { - logger := util.GetLogger() - logger.Error("Failed to parse control message", "error", err) - return - } - - // Handle both string and numeric type fields - var msgType string - switch v := message["type"].(type) { - case string: - msgType = v - case float64: - // Convert numeric type to string for clipboard messages - switch int(v) { - case 8: - msgType = "clipboard_get" - case 9: - msgType = "clipboard_set" - default: - logger := util.GetLogger() - logger.Error("Unknown numeric control message type", "type", int(v)) - return - } - default: - logger := util.GetLogger() - logger.Error("Control message missing or invalid type field", "message", message) - return - } - - // Log ping messages at debug level - if msgType == "ping" { - logger := util.GetLogger() - logger.Debug("Ping message received", - "data_length", len(msg.Data), - "data", string(msg.Data)) - } else { - // For non-ping messages, log appropriately - logger := util.GetLogger() - logger.Debug("DataChannel message received", - "data_length", len(msg.Data), - "data", string(msg.Data)) - - // Log touch/key events at debug level, others at info level - if msgType == "touch" || msgType == "key" { - logger.Debug("Received control message", "type", msgType) - } else { - logger.Info("Received control message", "type", msgType) - } - } - - switch msgType { - case "ping": - ch.handlePingMessage(message) - case "key": - ch.handleKeyEvent(message) - case "touch": - ch.handleTouchEvent(message) - case "scroll": - ch.handleScrollEvent(message) - case "reset_video": - ch.handleResetVideo(message) - case "clipboard_get": - ch.handleClipboardGet(message) - case "clipboard_set": - ch.handleClipboardSet(message) - default: - logger := util.GetLogger() - logger.Warn("Unknown control message type", "type", msgType) - } - }) - - logger.Debug("OnMessage handler set successfully for DataChannel") -} - -// handlePingMessage handles ping/pong messages for connection health -func (ch *ControlHandler) handlePingMessage(message map[string]interface{}) { - if id, hasId := message["id"].(string); hasId { - pongResponse := map[string]interface{}{ - "type": "pong", - "id": id, - "timestamp": time.Now().UnixNano() / int64(time.Millisecond), - } - - if pongData, err := json.Marshal(pongResponse); err == nil { - if ch.dataChannel != nil && ch.dataChannel.ReadyState() == webrtc.DataChannelStateOpen { - if err := ch.dataChannel.Send(pongData); err != nil { - logger := util.GetLogger() - logger.Error("Failed to send pong response", "error", err) - } else { - logger := util.GetLogger() - logger.Debug("Pong response sent", "ping_id", id) - } - } - } - } -} - -// handleKeyEvent processes keyboard events -func (ch *ControlHandler) handleKeyEvent(message map[string]interface{}) { - action, _ := message["action"].(string) - keycode, _ := message["keycode"].(float64) - metaState, _ := message["metaState"].(float64) - repeat, _ := message["repeat"].(float64) - - logger := util.GetLogger() - logger.Debug("Key event", "action", action, "keycode", int(keycode), "meta_state", int(metaState)) - - // Send to device via control connection - if ch.conn != nil { - ch.SendKeyEventToDevice(action, int(keycode), int(metaState), int(repeat)) - } -} - -// handleTouchEvent processes touch/mouse events -func (ch *ControlHandler) handleTouchEvent(message map[string]interface{}) { - action, _ := message["action"].(string) - x, _ := message["x"].(float64) - y, _ := message["y"].(float64) - pressure, _ := message["pressure"].(float64) - pointerId, _ := message["pointerId"].(float64) - - logger := util.GetLogger() - logger.Debug("Touch event", "action", action, "x", x, "y", y, "pressure", pressure) - - // Send to device via control connection - if ch.conn != nil { - logger := util.GetLogger() - logger.Debug("Sending touch event to device", "action", action, "x", x, "y", y) - ch.SendTouchEventToDevice(action, x, y, pressure, int(pointerId)) - } else { - logger := util.GetLogger() - logger.Debug("Control connection is nil, cannot send touch event") - } -} - -// handleScrollEvent processes scroll events -func (ch *ControlHandler) handleScrollEvent(message map[string]interface{}) { - x, _ := message["x"].(float64) - y, _ := message["y"].(float64) - hScroll, _ := message["hScroll"].(float64) - vScroll, _ := message["vScroll"].(float64) - - logger := util.GetLogger() - logger.Debug("Scroll event", "x", x, "y", y, "hScroll", hScroll, "vScroll", vScroll) - - // Send to device via control connection - if ch.conn != nil { - logger := util.GetLogger() - logger.Debug("Sending scroll event to device", "x", x, "y", y, "hScroll", hScroll, "vScroll", vScroll) - ch.SendScrollEventToDevice(x, y, hScroll, vScroll) - } else { - logger := util.GetLogger() - logger.Debug("Control connection is nil, cannot send scroll event - this is expected during initial connection setup") - // This is expected during initial connection setup, the connection will be updated later - // We could queue the event here if needed, but for now just log it - } -} - -// handleResetVideo handles video reset requests (keyframe) -func (ch *ControlHandler) handleResetVideo(message map[string]interface{}) { - logger := util.GetLogger() - logger.Info("Reset video requested (keyframe)") - // This would trigger a keyframe request -} - -// handleClipboardGet handles clipboard get requests -func (ch *ControlHandler) handleClipboardGet(message map[string]interface{}) { - logger := util.GetLogger() - logger.Info("Clipboard get requested") - // TODO: Implement clipboard get functionality - // This would get clipboard content from Android device and send it back -} - -// handleClipboardSet handles clipboard set requests -func (ch *ControlHandler) handleClipboardSet(message map[string]interface{}) { - logger := util.GetLogger() - logger.Info("Clipboard set requested") - - // Check if this is a JSON format message (new format) or binary format (old format) - if textInterface, ok := message["text"]; ok { - // JSON format: {"type": "clipboard_set", "text": "你好", "paste": true} - text, ok := textInterface.(string) - if !ok { - logger := util.GetLogger() - logger.Error("Clipboard set message text field is not a string") - return - } - - paste := false - if pasteInterface, ok := message["paste"]; ok { - if pasteBool, ok := pasteInterface.(bool); ok { - paste = pasteBool - } - } - - logger := util.GetLogger() - logger.Debug("Clipboard set (JSON format)", "text", text, "paste", paste) - - // Send clipboard data to Android device using scrcpy protocol - ch.sendClipboardToDevice(text, paste) - return - } - - // Binary format: extract data from message - dataInterface, ok := message["data"] - if !ok { - logger := util.GetLogger() - logger.Error("Clipboard set message missing both text and data fields") - return - } - - // Convert data to byte array - handle both array and map formats - var data []byte - - // Try array format first (new format) - if dataArray, ok := dataInterface.([]interface{}); ok { - for _, val := range dataArray { - if byteVal, ok := val.(float64); ok { - data = append(data, byte(byteVal)) - } - } - } else if dataMap, ok := dataInterface.(map[string]interface{}); ok { - // Fallback to map format (old format) - for i := 0; i < len(dataMap); i++ { - if val, exists := dataMap[fmt.Sprintf("%d", i)]; exists { - if byteVal, ok := val.(float64); ok { - data = append(data, byte(byteVal)) - } - } - } - } else { - logger := util.GetLogger() - logger.Error("Clipboard set message data is not in expected format (array or map)") - return - } - - if len(data) < 13 { - logger := util.GetLogger() - logger.Error("Clipboard set message data too short", "bytes", len(data)) - return - } - - // Parse clipboard data according to scrcpy protocol - // [Sequence (8 bytes)][Paste flag (1 byte)][Text length (4 bytes, big endian)][Text data] - // Note: Type is handled separately, not in data - sequence := int64(data[0])<<56 | int64(data[1])<<48 | int64(data[2])<<40 | int64(data[3])<<32 | - int64(data[4])<<24 | int64(data[5])<<16 | int64(data[6])<<8 | int64(data[7]) - pasteFlag := data[8] - textLength := int(data[9])<<24 | int(data[10])<<16 | int(data[11])<<8 | int(data[12]) - - if len(data) < 13+textLength { - logger := util.GetLogger() - logger.Error("Clipboard set message data incomplete", "expected", 13+textLength, "got", len(data)) - return - } - - text := string(data[13 : 13+textLength]) - logger.Debug("Clipboard set (binary format)", "sequence", sequence, "paste", pasteFlag, "text", text) - - // Send clipboard data to Android device using scrcpy protocol - ch.sendClipboardToDevice(text, pasteFlag == 1) -} - -// sendClipboardToDevice sends clipboard data to Android device -func (ch *ControlHandler) sendClipboardToDevice(text string, paste bool) { - if ch.conn == nil { - logger := util.GetLogger() - logger.Error("No connection available for clipboard operation") - return - } - - // Create clipboard control message according to scrcpy protocol - // Format: [Sequence (8 bytes)][Paste flag (1 byte)][Text length (4 bytes)][Text data] - // Note: Type is handled by ControlMessage.Type field, not in buffer - textBytes := []byte(text) - textLength := len(textBytes) - buffer := make([]byte, 8+1+4+textLength) - offset := 0 - - // Sequence (8 bytes, big endian) - use 0 for now - buffer[offset] = 0 - buffer[offset+1] = 0 - buffer[offset+2] = 0 - buffer[offset+3] = 0 - buffer[offset+4] = 0 - buffer[offset+5] = 0 - buffer[offset+6] = 0 - buffer[offset+7] = 0 - offset += 8 - - // Paste flag (1 byte) - 0 for just set, 1 for set and paste - if paste { - buffer[offset] = 1 - } else { - buffer[offset] = 0 - } - offset++ - - // Text length (4 bytes, big endian) - use actual text length - buffer[offset] = byte(textLength >> 24) - buffer[offset+1] = byte(textLength >> 16) - buffer[offset+2] = byte(textLength >> 8) - buffer[offset+3] = byte(textLength) - offset += 4 - - // Text data - copy(buffer[offset:], textBytes) - - // Debug: verify buffer size matches expected size - expectedSize := 8 + 1 + 4 + textLength - if len(buffer) != expectedSize { - logger := util.GetLogger() - logger.Error("Buffer size mismatch", "expected", expectedSize, "actual", len(buffer)) - } - - // Create control message - controlMsg := &device.ControlMessage{ - Type: protocol.ControlMsgTypeSetClipboard, - Sequence: 0, - Data: buffer, - } - - // Debug: log the buffer content - logger := util.GetLogger() - logger.Debug("Clipboard buffer", "length", len(buffer)) - if len(buffer) >= 20 { - logger.Debug("Clipboard buffer details", "first_20_bytes", buffer[:20], "last_20_bytes", buffer[len(buffer)-20:]) - } else { - logger.Debug("Clipboard buffer content", "buffer", buffer) - } - - // Send to device - ch.sendControlMessage(controlMsg) - logger.Info("Clipboard data sent to device", "text", text, "paste", paste) -} - -// SendKeyEventToDevice sends key event to Android device using protocol package -func (ch *ControlHandler) SendKeyEventToDevice(action string, keycode, metaState, repeat int) { - if ch.conn == nil { - return - } - - keyEvent := &protocol.KeyEvent{ - Action: action, - Keycode: keycode, - Repeat: repeat, - MetaState: metaState, - } - - controlMsg := &device.ControlMessage{ - Type: protocol.ControlMsgTypeInjectKeycode, - Sequence: 0, - Data: protocol.EncodeKeyEvent(*keyEvent), - } - - ch.sendControlMessage(controlMsg) -} - -// SendTouchEventToDevice sends touch event to Android device using protocol package -func (ch *ControlHandler) SendTouchEventToDevice(action string, x, y, pressure float64, pointerId int) { - if ch.conn == nil { - return - } - - // Check if touch point is within screen bounds - if x < 0 || x > float64(ch.screenWidth) || y < 0 || y > float64(ch.screenHeight) { - logger := util.GetLogger() - logger.Debug("Touch event outside screen bounds, ignoring", - "x", x, "y", y, "screen_width", ch.screenWidth, "screen_height", ch.screenHeight) - return - } - - touchEvent := &protocol.TouchEvent{ - Action: action, - X: x, - Y: y, - PointerID: pointerId, - Pressure: pressure, - } - - controlMsg := &device.ControlMessage{ - Type: protocol.ControlMsgTypeInjectTouchEvent, - Sequence: 0, - Data: protocol.EncodeTouchEvent(*touchEvent, ch.screenWidth, ch.screenHeight), - } - - ch.sendControlMessage(controlMsg) -} - -// SendScrollEventToDevice sends scroll event to Android device using protocol package -func (ch *ControlHandler) SendScrollEventToDevice(x, y, hScroll, vScroll float64) { - if ch.conn == nil { - logger := util.GetLogger() - logger.Debug("SendScrollEventToDevice: control connection is nil") - return - } - - scrollEvent := &protocol.ScrollEvent{ - X: x, - Y: y, - HScroll: hScroll, - VScroll: vScroll, - } - - logger := util.GetLogger() - logger.Debug("SendScrollEventToDevice: creating scroll event", "screen_width", ch.screenWidth, "screen_height", ch.screenHeight) - logger.Debug("SendScrollEventToDevice: scroll event data", "x", x, "y", y, "hScroll", hScroll, "vScroll", vScroll) - - controlMsg := &device.ControlMessage{ - Type: protocol.ControlMsgTypeInjectScrollEvent, - Sequence: 0, - Data: protocol.EncodeScrollEvent(*scrollEvent, ch.screenWidth, ch.screenHeight), - } - - logger.Debug("SendScrollEventToDevice: encoded data", "length", len(controlMsg.Data)) - ch.sendControlMessage(controlMsg) -} - -// sendControlMessage sends a control message to the device -func (ch *ControlHandler) sendControlMessage(msg *device.ControlMessage) { - if ch.conn == nil { - logger := util.GetLogger() - logger.Error("Control connection is nil, cannot send message", "type", msg.Type) - return - } - - // Serialize control message according to scrcpy protocol - // Format: [message_type][data] - buf := make([]byte, 1+len(msg.Data)) - buf[0] = byte(msg.Type) - copy(buf[1:], msg.Data) - - logger := util.GetLogger() - logger.Debug("Sending control message to device", "type", msg.Type, "data_len", len(msg.Data)) - - // Debug: log the actual data being sent to scrcpy server for clipboard messages - if msg.Type == protocol.ControlMsgTypeSetClipboard { - logger := util.GetLogger() - logger.Debug("Clipboard message details", "total_length", len(buf)) - if len(buf) >= 20 { - logger.Debug("Clipboard message data", "first_20_bytes", buf[:20], "last_20_bytes", buf[len(buf)-20:]) - } else { - logger.Debug("Clipboard message all data", "data", buf) - } - } - - if _, err := ch.conn.Write(buf); err != nil { - logger := util.GetLogger() - logger.Error("Failed to send control message", "error", err) - // Mark connection as invalid to prevent further attempts - ch.conn = nil - } else { - logger := util.GetLogger() - logger.Debug("Control message sent successfully") - } -} - -// SendKeyFrameRequest sends a keyframe request to the device -func (ch *ControlHandler) SendKeyFrameRequest() { - keyframeRequest := &device.ControlMessage{ - Type: protocol.ControlMsgTypeResetVideo, - Sequence: 0, - Data: []byte{}, - } - ch.sendControlMessage(keyframeRequest) -} - -// UpdateScreenDimensions updates the screen dimensions for coordinate conversion -func (ch *ControlHandler) UpdateScreenDimensions(width, height int) { - ch.screenWidth = width - ch.screenHeight = height - if util.IsVerbose() { - logger := util.GetLogger() - logger.Debug("Updated screen dimensions", "width", width, "height", height) - } -} - -// UpdateConnection updates the control connection -func (ch *ControlHandler) UpdateConnection(conn net.Conn) { - ch.conn = conn - if util.IsVerbose() { - logger := util.GetLogger() - logger.Debug("Updated control connection") - } -} - -// UpdateDataChannel updates the DataChannel -func (ch *ControlHandler) UpdateDataChannel(dataChannel *webrtc.DataChannel) { - ch.dataChannel = dataChannel - if util.IsVerbose() { - logger := util.GetLogger() - logger.Debug("Updated DataChannel") - } -} - -// HandleOutgoingMessages handles messages from device to WebRTC -func (ch *ControlHandler) HandleOutgoingMessages() { - if ch.conn == nil { - return - } - - // Read clipboard or other events from device - buffer := make([]byte, 4096) - for { - n, err := ch.conn.Read(buffer) - if err != nil { - if err != io.EOF { - logger := util.GetLogger() - logger.Error("Control stream read error", "error", err) - } - break - } - - if n > 0 { - // Process control response from device - logger := util.GetLogger() - logger.Debug("Received control response", "bytes", n) - } - } -} diff --git a/packages/cli/internal/device_connect/stream/video.go b/packages/cli/internal/device_connect/stream/video.go deleted file mode 100644 index c4b4ab8e..00000000 --- a/packages/cli/internal/device_connect/stream/video.go +++ /dev/null @@ -1,173 +0,0 @@ -package stream - -import ( - "encoding/binary" - "io" - "log" - "time" - - "github.com/pion/webrtc/v4" - "github.com/pion/webrtc/v4/pkg/media" -) - -// VideoHandler handles video stream processing -type VideoHandler struct { - track *webrtc.TrackLocalStaticSample - width int - height int - lastKeyframe time.Time -} - -// NewVideoHandler creates a new video stream handler -func NewVideoHandler(track *webrtc.TrackLocalStaticSample) *VideoHandler { - return &VideoHandler{ - track: track, - lastKeyframe: time.Now(), - } -} - -// HandleStream processes video stream from device -func (vh *VideoHandler) HandleStream(reader io.Reader) error { - // Read video metadata - metaBuf := make([]byte, 12) // codecId(4) + width(4) + height(4) - if _, err := io.ReadFull(reader, metaBuf); err != nil { - return err - } - - codecID := binary.BigEndian.Uint32(metaBuf[0:4]) - vh.width = int(binary.BigEndian.Uint32(metaBuf[4:8])) - vh.height = int(binary.BigEndian.Uint32(metaBuf[8:12])) - - log.Printf("Video stream started - Codec: %d, Resolution: %dx%d", codecID, vh.width, vh.height) - - // Start streaming video packets - return vh.streamPackets(reader) -} - -// streamPackets reads and processes video packets -func (vh *VideoHandler) streamPackets(reader io.Reader) error { - const maxPacketSize = 1024 * 1024 // 1MB max packet size - sequenceNumber := uint16(0) - - for { - // Read packet header (8 bytes: PTS high + PTS low) - header := make([]byte, 8) - if _, err := io.ReadFull(reader, header); err != nil { - if err == io.EOF { - log.Println("Video stream ended") - return nil - } - return err - } - - pts := binary.BigEndian.Uint64(header) - - // Read packet size (4 bytes) - sizeBuf := make([]byte, 4) - if _, err := io.ReadFull(reader, sizeBuf); err != nil { - return err - } - - packetSize := binary.BigEndian.Uint32(sizeBuf) - if packetSize > maxPacketSize { - log.Printf("Warning: Large video packet: %d bytes", packetSize) - continue - } - - // Read packet data - packetData := make([]byte, packetSize) - if _, err := io.ReadFull(reader, packetData); err != nil { - return err - } - - // Check for keyframe (H.264 NAL unit type) - if len(packetData) > 4 { - nalType := packetData[4] & 0x1F - if nalType == 5 || nalType == 7 || nalType == 8 { // IDR, SPS, PPS - vh.lastKeyframe = time.Now() - } - } - - // Fragment and send via RTP - if err := vh.sendVideoPacket(packetData, pts, &sequenceNumber); err != nil { - log.Printf("Error sending video packet: %v", err) - } - } -} - -// sendVideoPacket sends video data as RTP packets -func (vh *VideoHandler) sendVideoPacket(data []byte, pts uint64, seqNum *uint16) error { - if vh.track == nil { - return nil - } - - const maxRTPPayloadSize = 1200 // Leave room for RTP headers - - // Fragment large NAL units - if len(data) <= maxRTPPayloadSize { - // Single NAL unit - write directly as sample - *seqNum++ - - sample := media.Sample{ - Data: data, - Duration: time.Millisecond * 33, // ~30 fps - } - return vh.track.WriteSample(sample) - } - - // Fragment into FU-A packets (H.264 fragmentation) - nalHeader := data[0] - data = data[1:] // Skip NAL header - - for len(data) > 0 { - payloadSize := len(data) - if payloadSize > maxRTPPayloadSize-2 { // -2 for FU header - payloadSize = maxRTPPayloadSize - 2 - } - - // Build FU-A header - fuHeader := make([]byte, 2) - fuHeader[0] = (nalHeader & 0xE0) | 28 // FU-A type - fuHeader[1] = nalHeader & 0x1F // Original NAL type - - if len(data) == payloadSize { - fuHeader[1] |= 0x40 // End bit - } - if len(data) == len(data) { // First fragment (when data is still complete) - fuHeader[1] |= 0x80 // Start bit - } - - payload := append(fuHeader, data[:payloadSize]...) - marker := len(data) == payloadSize // Last fragment - - // No longer need RTP packet for WriteSample - *seqNum++ - _ = marker // Mark as used - - // Collect all fragments and write complete NAL unit - if marker { - sample := media.Sample{ - Data: payload, - Duration: time.Millisecond * 33, - } - if err := vh.track.WriteSample(sample); err != nil { - return err - } - } - - data = data[payloadSize:] - } - - return nil -} - -// GetDimensions returns the video dimensions -func (vh *VideoHandler) GetDimensions() (int, int) { - return vh.width, vh.height -} - -// RequestKeyframe requests a keyframe from the encoder -func (vh *VideoHandler) RequestKeyframe() { - // This would send a request to the device for a keyframe - log.Println("Keyframe requested") -} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/transport/control/clipboard.go b/packages/cli/internal/device_connect/transport/control/clipboard.go new file mode 100644 index 00000000..f1fd7e7d --- /dev/null +++ b/packages/cli/internal/device_connect/transport/control/clipboard.go @@ -0,0 +1,59 @@ +package control + +import ( + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// handleClipboardGet handles clipboard get requests +func (h *Handler) handleClipboardGet(message map[string]interface{}) { + util.GetLogger().Info("Clipboard get requested") + h.sendClipboardToDevice("", false) +} + +// handleClipboardSet handles clipboard set requests +func (h *Handler) handleClipboardSet(message map[string]interface{}) { + text, ok := message["text"].(string) + if !ok { + util.GetLogger().Error("Invalid clipboard text") + return + } + + paste, ok := message["paste"].(bool) + if !ok { + paste = false + } + + util.GetLogger().Info("Clipboard set requested", "text", text, "paste", paste) + h.sendClipboardToDevice(text, paste) +} + +// sendClipboardToDevice sends clipboard data to the device +func (h *Handler) sendClipboardToDevice(text string, paste bool) { + logger := util.GetLogger() + logger.Debug("Sending clipboard to device", "text", text, "paste", paste) + + // Create control message for setting clipboard + msg := &protocol.ControlMessage{ + Type: protocol.ControlMsgTypeSetClipboard, + Data: []byte(text), // Text content as data + } + + h.sendControlMessage(msg) + + // If paste is requested, also send the text as input + if paste && text != "" { + time.Sleep(100 * time.Millisecond) // Small delay + h.handleInjectText(map[string]interface{}{ + "text": text, + }) + } +} + +// HandleOutgoingMessages handles outgoing control messages (for future use) +func (h *Handler) HandleOutgoingMessages() { + // This can be used for handling outgoing messages in the future + // For now, it's a placeholder +} diff --git a/packages/cli/internal/device_connect/transport/control/handler.go b/packages/cli/internal/device_connect/transport/control/handler.go new file mode 100644 index 00000000..4463528e --- /dev/null +++ b/packages/cli/internal/device_connect/transport/control/handler.go @@ -0,0 +1,185 @@ +package control + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" + "github.com/babelcloud/gbox/packages/cli/internal/util" + "github.com/pion/webrtc/v4" +) + +// Handler handles control stream and messages +type Handler struct { + conn net.Conn + dataChannel *webrtc.DataChannel + screenWidth int + screenHeight int + source core.Source // Reference to scrcpy source for sending control messages +} + +// NewHandler creates a new control stream handler +func NewHandler(conn net.Conn, dataChannel *webrtc.DataChannel, screenWidth, screenHeight int) *Handler { + logger := util.GetLogger() + logger.Debug("Creating control handler", + "conn_available", conn != nil, + "datachannel_available", dataChannel != nil, + "screen_width", screenWidth, + "screen_height", screenHeight) + return &Handler{ + conn: conn, + dataChannel: dataChannel, + screenWidth: screenWidth, + screenHeight: screenHeight, + source: nil, // Will be set later + } +} + +// HandleIncomingMessages handles control messages from WebRTC +func (h *Handler) HandleIncomingMessages() { + logger := util.GetLogger() + logger.Debug("HandleIncomingMessages called") + + if h.dataChannel == nil { + logger.Error("DataChannel is nil, cannot set up message handling") + return + } + + logger.Debug("Setting up DataChannel message handling", + "state", h.dataChannel.ReadyState()) + + h.dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) { + // Parse control message first to determine if it's a ping + var message map[string]interface{} + if err := json.Unmarshal(msg.Data, &message); err != nil { + logger := util.GetLogger() + logger.Error("Failed to parse control message", "error", err) + return + } + + // Handle both string and numeric type fields + var msgType string + switch v := message["type"].(type) { + case string: + msgType = v + case float64: // JSON numbers are float64 + msgType = fmt.Sprintf("%d", int(v)) + default: + logger.Error("Unknown message type format", "type", v) + return + } + + logger.Debug("Received control message", "type", msgType) + + switch msgType { + case "ping": + // Respond to ping to keep connection alive + h.handlePingMessage(message) + case "touch": + // Handle touch events (mouse events from frontend) + h.handleTouchEvent(message) + case "mousemove", "mousedown", "mouseup": + // Legacy mouse event support + h.handleTouchEvent(message) + case "scroll": + h.handleScrollEvent(message) + case "key": + // Handle key events + h.handleKeyEvent(message) + case "keydown", "keyup": + // Legacy key event support + h.handleKeyEvent(message) + case "inject_text": + h.handleInjectText(message) + case "clipboard_set", "set_clipboard": + h.handleClipboardSet(message) + case "clipboard_get", "get_clipboard": + h.handleClipboardGet(message) + case "request_keyframe": + h.SendKeyFrameRequest() + case "reset_video": + h.handleResetVideo(message) + case "power_on": + h.SendKeyEventToDevice("down", 26, 0, 0) // Power key + case "power_off": + h.SendKeyEventToDevice("down", 26, 0, 0) // Power key + case "rotate_device": + h.SendKeyEventToDevice("down", 82, 0, 0) // Menu key + case "expand_notification_panel": + h.SendKeyEventToDevice("down", 82, 0, 0) // Menu key + case "expand_settings_panel": + h.SendKeyEventToDevice("down", 82, 0, 0) // Menu key + case "collapse_panels": + h.SendKeyEventToDevice("down", 4, 0, 0) // Back key + case "back_or_screen_on": + h.SendKeyEventToDevice("down", 4, 0, 0) // Back key + case "home": + h.SendKeyEventToDevice("down", 3, 0, 0) // Home key + case "app_switch": + h.SendKeyEventToDevice("down", 187, 0, 0) // App switch key + case "menu": + h.SendKeyEventToDevice("down", 82, 0, 0) // Menu key + case "volume_up": + h.SendKeyEventToDevice("down", 24, 0, 0) // Volume up key + case "volume_down": + h.SendKeyEventToDevice("down", 25, 0, 0) // Volume down key + default: + logger.Warn("Unknown control message type", "type", msgType) + } + }) +} + +// UpdateDataChannel updates the DataChannel for the control handler +func (h *Handler) UpdateDataChannel(dataChannel *webrtc.DataChannel) { + h.dataChannel = dataChannel +} + +// UpdateConnection updates the control connection +func (h *Handler) UpdateConnection(conn net.Conn) { + h.conn = conn +} + +// UpdateScreenDimensions updates the screen dimensions +func (h *Handler) UpdateScreenDimensions(width, height int) { + logger := util.GetLogger() + logger.Info("Updating screen dimensions", "width", width, "height", height) + h.screenWidth = width + h.screenHeight = height +} + +// SetSource sets the scrcpy source for sending control messages +func (h *Handler) SetSource(source core.Source) { + h.source = source +} + +// sendControlMessage sends a control message to the device +func (h *Handler) sendControlMessage(msg *protocol.ControlMessage) { + logger := util.GetLogger() + + // Try to send via scrcpy source first (preferred for WebRTC) + if h.source != nil { + coreMsg := core.ControlMessage{ + Type: int32(msg.Type), + Data: msg.Data, + } + if err := h.source.SendControl(coreMsg); err != nil { + logger.Error("Failed to send control message via source", "error", err) + return + } + logger.Debug("Control message sent via source") + return + } + + // Fallback to direct connection (legacy) + if h.conn == nil { + logger.Error("Control connection is nil, cannot send message") + return + } + + data := protocol.SerializeControlMessage(msg) + if _, err := h.conn.Write(data); err != nil { + logger.Error("Failed to send control message", "error", err) + } +} diff --git a/packages/cli/internal/device_connect/transport/control/input.go b/packages/cli/internal/device_connect/transport/control/input.go new file mode 100644 index 00000000..cce8a03b --- /dev/null +++ b/packages/cli/internal/device_connect/transport/control/input.go @@ -0,0 +1,247 @@ +package control + +import ( + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" + "github.com/babelcloud/gbox/packages/cli/internal/util" + "github.com/pion/webrtc/v4" +) + +// handlePingMessage handles ping messages to keep connection alive +func (h *Handler) handlePingMessage(message map[string]interface{}) { + logger := util.GetLogger() + logger.Debug("Received ping message") + + // Send pong response + if h.dataChannel != nil && h.dataChannel.ReadyState() == webrtc.DataChannelStateOpen { + if err := h.dataChannel.SendText("pong"); err != nil { + logger.Error("Failed to send pong", "error", err) + } + } +} + +// handleKeyEvent handles keyboard events +func (h *Handler) handleKeyEvent(message map[string]interface{}) { + action, ok := message["action"].(string) + if !ok { + util.GetLogger().Error("Invalid key action") + return + } + + keycode, ok := message["keycode"].(float64) + if !ok { + util.GetLogger().Error("Invalid keycode") + return + } + + metaState, ok := message["metaState"].(float64) + if !ok { + metaState = 0 + } + + repeat, ok := message["repeat"].(float64) + if !ok { + repeat = 0 + } + + h.SendKeyEventToDevice(action, int(keycode), int(metaState), int(repeat)) +} + +// handleTouchEvent handles touch events (mouse events) +func (h *Handler) handleTouchEvent(message map[string]interface{}) { + // Handle both string and numeric action values + var actionStr string + if action, ok := message["action"].(string); ok { + // Frontend sends string action + actionStr = action + } else if action, ok := message["action"].(float64); ok { + // Legacy numeric action support + switch int(action) { + case 0: + actionStr = "down" + case 1: + actionStr = "up" + case 2: + actionStr = "move" + default: + actionStr = "move" + } + } else { + util.GetLogger().Error("Invalid touch action") + return + } + + x, ok := message["x"].(float64) + if !ok { + util.GetLogger().Error("Invalid touch x coordinate") + return + } + + y, ok := message["y"].(float64) + if !ok { + util.GetLogger().Error("Invalid touch y coordinate") + return + } + + pressure, ok := message["pressure"].(float64) + if !ok { + pressure = 1.0 + } + + pointerId, ok := message["pointerId"].(float64) + if !ok { + pointerId = 0 + } + + h.SendTouchEventToDevice(actionStr, x, y, pressure, int(pointerId)) +} + +// handleScrollEvent handles scroll events +func (h *Handler) handleScrollEvent(message map[string]interface{}) { + x, ok := message["x"].(float64) + if !ok { + util.GetLogger().Error("Invalid scroll x coordinate") + return + } + + y, ok := message["y"].(float64) + if !ok { + util.GetLogger().Error("Invalid scroll y coordinate") + return + } + + hScroll, ok := message["hScroll"].(float64) + if !ok { + hScroll = 0 + } + + vScroll, ok := message["vScroll"].(float64) + if !ok { + vScroll = 0 + } + + h.SendScrollEventToDevice(x, y, hScroll, vScroll) +} + +// handleInjectText handles text injection +func (h *Handler) handleInjectText(message map[string]interface{}) { + text, ok := message["text"].(string) + if !ok { + util.GetLogger().Error("Invalid text for injection") + return + } + + // Send text input events + for _, char := range text { + // Convert character to keycode (simplified) + keycode := int(char) + if keycode > 127 { + keycode = 0 // Unknown character + } + + // Send key down + h.SendKeyEventToDevice("down", keycode, 0, 0) + time.Sleep(10 * time.Millisecond) + // Send key up + h.SendKeyEventToDevice("up", keycode, 0, 0) + time.Sleep(10 * time.Millisecond) + } +} + +// handleResetVideo handles video reset requests +func (h *Handler) handleResetVideo(message map[string]interface{}) { + util.GetLogger().Info("Video reset requested") + // Request a keyframe + h.SendKeyFrameRequest() +} + +// SendKeyEventToDevice sends a key event to the device +func (h *Handler) SendKeyEventToDevice(action string, keycode, metaState, repeat int) { + logger := util.GetLogger() + logger.Debug("Sending key event", "action", action, "keycode", keycode, "metaState", metaState, "repeat", repeat) + + // Create key event + keyEvent := protocol.KeyEvent{ + Action: action, + Keycode: keycode, + MetaState: metaState, + Repeat: repeat, + } + + // Encode key event + data := protocol.EncodeKeyEvent(keyEvent) + + // Create control message + msg := &protocol.ControlMessage{ + Type: protocol.ControlMsgTypeInjectKeycode, + Data: data, + } + + h.sendControlMessage(msg) +} + +// SendTouchEventToDevice sends a touch event to the device +func (h *Handler) SendTouchEventToDevice(action string, x, y, pressure float64, pointerId int) { + logger := util.GetLogger() + logger.Debug("Sending touch event", "action", action, "x", x, "y", y, "pressure", pressure, "pointerId", pointerId, "screenWidth", h.screenWidth, "screenHeight", h.screenHeight) + + // Create touch event + touchEvent := protocol.TouchEvent{ + Action: action, + X: x, + Y: y, + Pressure: pressure, + PointerID: pointerId, + } + + // Encode touch event + data := protocol.EncodeTouchEvent(touchEvent, h.screenWidth, h.screenHeight) + + // Create control message + msg := &protocol.ControlMessage{ + Type: protocol.ControlMsgTypeInjectTouchEvent, + Data: data, + } + + h.sendControlMessage(msg) +} + +// SendScrollEventToDevice sends a scroll event to the device +func (h *Handler) SendScrollEventToDevice(x, y, hScroll, vScroll float64) { + logger := util.GetLogger() + logger.Debug("Sending scroll event", "x", x, "y", y, "hScroll", hScroll, "vScroll", vScroll) + + // Create scroll event + scrollEvent := protocol.ScrollEvent{ + X: x, + Y: y, + HScroll: hScroll, + VScroll: vScroll, + } + + // Encode scroll event + data := protocol.EncodeScrollEvent(scrollEvent, h.screenWidth, h.screenHeight) + + // Create control message + msg := &protocol.ControlMessage{ + Type: protocol.ControlMsgTypeInjectScrollEvent, + Data: data, + } + + h.sendControlMessage(msg) +} + +// SendKeyFrameRequest sends a keyframe request to the device +func (h *Handler) SendKeyFrameRequest() { + logger := util.GetLogger() + logger.Debug("Sending keyframe request") + + // Create control message for video reset (which requests keyframe) + msg := &protocol.ControlMessage{ + Type: protocol.ControlMsgTypeResetVideo, + Data: []byte{}, // Empty data for reset video + } + + h.sendControlMessage(msg) +} diff --git a/packages/cli/internal/device_connect/transport/control/interface.go b/packages/cli/internal/device_connect/transport/control/interface.go new file mode 100644 index 00000000..537d0d5e --- /dev/null +++ b/packages/cli/internal/device_connect/transport/control/interface.go @@ -0,0 +1,38 @@ +package control + +import ( + "net" + + "github.com/pion/webrtc/v4" +) + +// HandlerInterface defines the interface for control handlers +// This allows different transport types to implement their own control handling +type HandlerInterface interface { + // HandleIncomingMessages starts handling incoming control messages + HandleIncomingMessages() + + // UpdateDataChannel updates the WebRTC data channel (for WebRTC transport) + UpdateDataChannel(dataChannel *webrtc.DataChannel) + + // UpdateConnection updates the control connection (for non-WebRTC transports) + UpdateConnection(conn net.Conn) + + // UpdateScreenDimensions updates the screen dimensions + UpdateScreenDimensions(width, height int) + + // SetSource sets the scrcpy source for sending control messages + SetSource(source interface{}) // Using interface{} to avoid circular imports + + // SendKeyFrameRequest sends a keyframe request + SendKeyFrameRequest() + + // SendKeyEventToDevice sends a key event to the device + SendKeyEventToDevice(action string, keycode, metaState, repeat int) + + // SendTouchEventToDevice sends a touch event to the device + SendTouchEventToDevice(action string, x, y, pressure float64, pointerId int) + + // SendScrollEventToDevice sends a scroll event to the device + SendScrollEventToDevice(x, y, hScroll, vScroll float64) +} diff --git a/packages/cli/internal/device_connect/transport/h264/annexb_to_avc.go b/packages/cli/internal/device_connect/transport/h264/annexb_to_avc.go new file mode 100644 index 00000000..25d2d4ef --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/annexb_to_avc.go @@ -0,0 +1,261 @@ +package h264 + +import ( + "fmt" + "io" +) + +// AnnexBToAVCConverter converts H.264 Annex-B format to AVC format +type AnnexBToAVCConverter struct { + buffer []byte +} + +// NewAnnexBToAVCConverter creates a new converter +func NewAnnexBToAVCConverter() *AnnexBToAVCConverter { + return &AnnexBToAVCConverter{ + buffer: make([]byte, 0, 1024*1024), // 1MB initial capacity + } +} + +// Convert converts H.264 Annex-B data to AVC format +// Annex-B uses 0x00000001 or 0x000001 as start codes +// AVC uses 4-byte length prefixes (big-endian) +func (c *AnnexBToAVCConverter) Convert(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, nil + } + + // Reset buffer + c.buffer = c.buffer[:0] + + // Find NAL units and convert them + offset := 0 + for offset < len(data) { + // Look for start code (0x00000001 or 0x000001) + startCodePos := c.findStartCode(data[offset:]) + if startCodePos == -1 { + // No more start codes found, add remaining data as last NAL unit + if offset < len(data) { + nalData := data[offset:] + if len(nalData) > 0 { + length := uint32(len(nalData)) + c.buffer = append(c.buffer, + byte(length>>24), + byte(length>>16), + byte(length>>8), + byte(length), + ) + c.buffer = append(c.buffer, nalData...) + } + } + break + } + + // Calculate actual start code position + actualPos := offset + startCodePos + + // If we have data before this start code, it's part of the previous NAL unit + if actualPos > offset { + nalData := data[offset:actualPos] + length := uint32(len(nalData)) + c.buffer = append(c.buffer, + byte(length>>24), + byte(length>>16), + byte(length>>8), + byte(length), + ) + c.buffer = append(c.buffer, nalData...) + } + + // Skip the start code + startCodeLen := c.getStartCodeLength(data[actualPos:]) + offset = actualPos + startCodeLen + } + + return c.buffer, nil +} + +// findStartCode finds the position of the next start code in the data +// Returns -1 if no start code is found +func (c *AnnexBToAVCConverter) findStartCode(data []byte) int { + for i := 0; i < len(data)-3; i++ { + // Check for 0x00000001 + if data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x00 && data[i+3] == 0x01 { + return i + } + // Check for 0x000001 (but not 0x00000001) + if i < len(data)-2 && data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x01 { + // Make sure it's not 0x00000001 + if i == 0 || data[i-1] != 0x00 { + return i + } + } + } + return -1 +} + +// getStartCodeLength returns the length of the start code at the given position +func (c *AnnexBToAVCConverter) getStartCodeLength(data []byte) int { + if len(data) >= 4 && data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x00 && data[3] == 0x01 { + return 4 + } + if len(data) >= 3 && data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x01 { + return 3 + } + return 0 +} + +// ConvertStream converts a stream of H.264 Annex-B data to AVC format +func (c *AnnexBToAVCConverter) ConvertStream(input io.Reader, output io.Writer) error { + buffer := make([]byte, 64*1024) // 64KB buffer + var remaining []byte + + for { + n, err := input.Read(buffer) + if n > 0 { + // Combine remaining data with new data + data := append(remaining, buffer[:n]...) + + // Convert the data + avcData, convertErr := c.Convert(data) + if convertErr != nil { + return fmt.Errorf("conversion error: %w", convertErr) + } + + // Write converted data + if len(avcData) > 0 { + if _, writeErr := output.Write(avcData); writeErr != nil { + return fmt.Errorf("write error: %w", writeErr) + } + } + + // Handle remaining data that might be part of an incomplete NAL unit + remaining = c.getRemainingData(data) + } + + if err == io.EOF { + // Process any remaining data + if len(remaining) > 0 { + avcData, convertErr := c.Convert(remaining) + if convertErr != nil { + return fmt.Errorf("final conversion error: %w", convertErr) + } + if len(avcData) > 0 { + if _, writeErr := output.Write(avcData); writeErr != nil { + return fmt.Errorf("final write error: %w", writeErr) + } + } + } + break + } + + if err != nil { + return fmt.Errorf("read error: %w", err) + } + } + + return nil +} + +// getRemainingData returns data that might be part of an incomplete NAL unit +func (c *AnnexBToAVCConverter) getRemainingData(data []byte) []byte { + // Look for the last complete start code + lastStartCode := -1 + for i := 0; i < len(data)-3; i++ { + if data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x00 && data[i+3] == 0x01 { + lastStartCode = i + 4 + } else if i < len(data)-2 && data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x01 { + if i == 0 || data[i-1] != 0x00 { + lastStartCode = i + 3 + } + } + } + + if lastStartCode == -1 { + // No start code found, all data might be remaining + return data + } + + // Return data after the last start code + if lastStartCode < len(data) { + return data[lastStartCode:] + } + + return nil +} + +// ValidateAnnexBData validates that the data is in Annex-B format +func ValidateAnnexBData(data []byte) bool { + if len(data) < 4 { + return false + } + + // Check for start code at the beginning + if data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x00 && data[3] == 0x01 { + return true + } + if len(data) >= 3 && data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x01 { + return true + } + + return false +} + +// ValidateAVCData validates that the data is in AVC format +func ValidateAVCData(data []byte) bool { + if len(data) < 4 { + return false + } + + // Check that it starts with a length prefix (not a start code) + if data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x00 && data[3] == 0x01 { + return false // This is Annex-B format + } + if len(data) >= 3 && data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x01 { + return false // This is Annex-B format + } + + // Check for reasonable length prefix + length := uint32(data[0])<<24 | uint32(data[1])<<16 | uint32(data[2])<<8 | uint32(data[3]) + if length > uint32(len(data)) { + return false // Length exceeds available data + } + + return true +} + +// ConvertAnnexBToAVC is a convenience function for one-shot conversion +func ConvertAnnexBToAVC(data []byte) ([]byte, error) { + converter := NewAnnexBToAVCConverter() + return converter.Convert(data) +} + +// ConvertAVCToAnnexB converts AVC format back to Annex-B format (for testing) +func ConvertAVCToAnnexB(data []byte) ([]byte, error) { + var result []byte + offset := 0 + + for offset < len(data) { + if offset+4 > len(data) { + break // Not enough data for length prefix + } + + // Read length prefix + length := uint32(data[offset])<<24 | uint32(data[offset+1])<<16 | uint32(data[offset+2])<<8 | uint32(data[offset+3]) + offset += 4 + + if offset+int(length) > len(data) { + return nil, fmt.Errorf("invalid length prefix: %d", length) + } + + // Add start code + result = append(result, 0x00, 0x00, 0x00, 0x01) + + // Add NAL unit data + result = append(result, data[offset:offset+int(length)]...) + + offset += int(length) + } + + return result, nil +} diff --git a/packages/cli/internal/device_connect/transport/h264/annexb_to_avc_test.go b/packages/cli/internal/device_connect/transport/h264/annexb_to_avc_test.go new file mode 100644 index 00000000..d07c1b68 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/annexb_to_avc_test.go @@ -0,0 +1,213 @@ +package h264 + +import ( + "testing" +) + +func TestAnnexBToAVCConversion(t *testing.T) { + // Test data: SPS NAL unit in Annex-B format + annexBData := []byte{ + 0x00, 0x00, 0x00, 0x01, // Start code + 0x67, 0x42, 0x00, 0x1e, 0x96, 0x54, 0x05, 0x01, 0xed, 0x80, // SPS data + 0x00, 0x00, 0x00, 0x01, // Start code + 0x68, 0xce, 0x38, 0x80, // PPS data + } + + converter := NewAnnexBToAVCConverter() + avcData, err := converter.Convert(annexBData) + if err != nil { + t.Fatalf("Conversion failed: %v", err) + } + + // Check that we got AVC format data + if len(avcData) == 0 { + t.Fatal("No AVC data returned") + } + + // Verify format: should start with length prefix, not start code + if avcData[0] == 0x00 && avcData[1] == 0x00 && avcData[2] == 0x00 && avcData[3] == 0x01 { + t.Fatal("AVC data still contains start codes") + } + + // Check that we have length prefixes + if len(avcData) < 8 { + t.Fatal("AVC data too short") + } + + // First NAL unit length (SPS) + spsLength := uint32(avcData[0])<<24 | uint32(avcData[1])<<16 | uint32(avcData[2])<<8 | uint32(avcData[3]) + if spsLength != 10 { + t.Fatalf("Expected SPS length 10, got %d", spsLength) + } + + // Second NAL unit length (PPS) + ppsOffset := 4 + int(spsLength) + if ppsOffset+4 > len(avcData) { + t.Fatal("Not enough data for PPS length prefix") + } + ppsLength := uint32(avcData[ppsOffset])<<24 | uint32(avcData[ppsOffset+1])<<16 | uint32(avcData[ppsOffset+2])<<8 | uint32(avcData[ppsOffset+3]) + if ppsLength != 4 { + t.Fatalf("Expected PPS length 4, got %d", ppsLength) + } + + t.Logf("Successfully converted Annex-B to AVC: %d bytes -> %d bytes", len(annexBData), len(avcData)) +} + +func TestAVCToAnnexBConversion(t *testing.T) { + // Test data: SPS and PPS in AVC format + avcData := []byte{ + // SPS length (10 bytes) + SPS data + 0x00, 0x00, 0x00, 0x0a, // Length prefix + 0x67, 0x42, 0x00, 0x1e, 0x96, 0x54, 0x05, 0x01, 0xed, 0x80, // SPS data + // PPS length (4 bytes) + PPS data + 0x00, 0x00, 0x00, 0x04, // Length prefix + 0x68, 0xce, 0x38, 0x80, // PPS data + } + + annexBData, err := ConvertAVCToAnnexB(avcData) + if err != nil { + t.Fatalf("Conversion failed: %v", err) + } + + // Check that we got Annex-B format data + if len(annexBData) == 0 { + t.Fatal("No Annex-B data returned") + } + + // Verify format: should start with start code + if !(annexBData[0] == 0x00 && annexBData[1] == 0x00 && annexBData[2] == 0x00 && annexBData[3] == 0x01) { + t.Fatal("Annex-B data doesn't start with start code") + } + + // Check that we have two NAL units + startCodeCount := 0 + for i := 0; i < len(annexBData)-3; i++ { + if annexBData[i] == 0x00 && annexBData[i+1] == 0x00 && annexBData[i+2] == 0x00 && annexBData[i+3] == 0x01 { + startCodeCount++ + } + } + if startCodeCount != 2 { + t.Fatalf("Expected 2 start codes, got %d", startCodeCount) + } + + t.Logf("Successfully converted AVC to Annex-B: %d bytes -> %d bytes", len(avcData), len(annexBData)) +} + +func TestRoundTripConversion(t *testing.T) { + // Test round-trip conversion: Annex-B -> AVC -> Annex-B + originalAnnexB := []byte{ + 0x00, 0x00, 0x00, 0x01, // Start code + 0x67, 0x42, 0x00, 0x1e, 0x96, 0x54, 0x05, 0x01, 0xed, 0x80, // SPS data + 0x00, 0x00, 0x00, 0x01, // Start code + 0x68, 0xce, 0x38, 0x80, // PPS data + } + + // Convert to AVC + converter := NewAnnexBToAVCConverter() + avcData, err := converter.Convert(originalAnnexB) + if err != nil { + t.Fatalf("Annex-B to AVC conversion failed: %v", err) + } + + // Convert back to Annex-B + convertedAnnexB, err := ConvertAVCToAnnexB(avcData) + if err != nil { + t.Fatalf("AVC to Annex-B conversion failed: %v", err) + } + + // Compare NAL unit data (excluding start codes) + originalNals := extractNALUnits(originalAnnexB) + convertedNals := extractNALUnits(convertedAnnexB) + + if len(originalNals) != len(convertedNals) { + t.Fatalf("Different number of NAL units: original=%d, converted=%d", len(originalNals), len(convertedNals)) + } + + for i, originalNal := range originalNals { + convertedNal := convertedNals[i] + if len(originalNal) != len(convertedNal) { + t.Fatalf("NAL unit %d length mismatch: original=%d, converted=%d", i, len(originalNal), len(convertedNal)) + } + for j, b := range originalNal { + if convertedNal[j] != b { + t.Fatalf("NAL unit %d byte %d mismatch: original=0x%02x, converted=0x%02x", i, j, b, convertedNal[j]) + } + } + } + + t.Logf("Round-trip conversion successful: %d bytes -> %d bytes -> %d bytes", + len(originalAnnexB), len(avcData), len(convertedAnnexB)) +} + +func TestValidation(t *testing.T) { + // Test Annex-B validation + annexBData := []byte{0x00, 0x00, 0x00, 0x01, 0x67, 0x42} + if !ValidateAnnexBData(annexBData) { + t.Error("Valid Annex-B data not recognized") + } + + invalidData := []byte{0x01, 0x02, 0x03, 0x04} + if ValidateAnnexBData(invalidData) { + t.Error("Invalid data recognized as Annex-B") + } + + // Test AVC validation + avcData := []byte{0x00, 0x00, 0x00, 0x04, 0x67, 0x42, 0x00, 0x1e} + if !ValidateAVCData(avcData) { + t.Error("Valid AVC data not recognized") + } + + if ValidateAVCData(annexBData) { + t.Error("Annex-B data recognized as AVC") + } +} + +// Helper function to extract NAL units from Annex-B data +func extractNALUnits(data []byte) [][]byte { + var nals [][]byte + offset := 0 + + for offset < len(data) { + // Find start code + startCodePos := -1 + for i := offset; i < len(data)-3; i++ { + if data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x00 && data[i+3] == 0x01 { + startCodePos = i + break + } + } + + if startCodePos == -1 { + break + } + + // Skip start code + startCodeLen := 4 + nalStart := startCodePos + startCodeLen + + // Find next start code + nextStartCodePos := -1 + for i := nalStart; i < len(data)-3; i++ { + if data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x00 && data[i+3] == 0x01 { + nextStartCodePos = i + break + } + } + + var nalEnd int + if nextStartCodePos == -1 { + nalEnd = len(data) + } else { + nalEnd = nextStartCodePos + } + + // Extract NAL unit + nal := make([]byte, nalEnd-nalStart) + copy(nal, data[nalStart:nalEnd]) + nals = append(nals, nal) + + offset = nalEnd + } + + return nals +} diff --git a/packages/cli/internal/device_connect/transport/h264/control.go b/packages/cli/internal/device_connect/transport/h264/control.go new file mode 100644 index 00000000..d9d5ff43 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/control.go @@ -0,0 +1,46 @@ +package h264 + +import ( + "net" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/control" +) + +// ControlHandler wraps the shared control handler for H.264 transport +type ControlHandler struct { + *control.Handler +} + +// NewControlHandler creates a new H.264 control handler +func NewControlHandler(conn net.Conn, screenWidth, screenHeight int) *ControlHandler { + // Create shared control handler with connection (no DataChannel for H.264) + sharedHandler := control.NewHandler(conn, nil, screenWidth, screenHeight) + + return &ControlHandler{ + Handler: sharedHandler, + } +} + +// SetSource sets the scrcpy source for sending control messages +func (h *ControlHandler) SetSource(source core.Source) { + h.Handler.SetSource(source) +} + +// UpdateConnection updates the control connection +func (h *ControlHandler) UpdateConnection(conn net.Conn) { + h.Handler.UpdateConnection(conn) +} + +// UpdateScreenDimensions updates the screen dimensions +func (h *ControlHandler) UpdateScreenDimensions(width, height int) { + h.Handler.UpdateScreenDimensions(width, height) +} + +// HandleIncomingMessages handles incoming control messages +func (h *ControlHandler) HandleIncomingMessages() { + // For H.264, we don't have WebRTC DataChannel, so we handle messages differently + // This could be implemented to read from the connection directly + // For now, we'll use the shared handler's logic + h.Handler.HandleIncomingMessages() +} diff --git a/packages/cli/internal/device_connect/transport/h264/handler_avc.go b/packages/cli/internal/device_connect/transport/h264/handler_avc.go new file mode 100644 index 00000000..85cfbb66 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/handler_avc.go @@ -0,0 +1,130 @@ +package h264 + +import ( + "fmt" + "net/http" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// AVCHTTPHandler handles HTTP-based AVC format H.264 streaming +type AVCHTTPHandler struct { + deviceSerial string + converter *AnnexBToAVCConverter +} + +// NewAVCHTTPHandler creates a new HTTP handler for AVC format H.264 streaming +func NewAVCHTTPHandler(deviceSerial string) *AVCHTTPHandler { + return &AVCHTTPHandler{ + deviceSerial: deviceSerial, + converter: NewAnnexBToAVCConverter(), + } +} + +// ServeHTTP implements http.Handler for AVC format H.264 streaming +func (h *AVCHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + logger := util.GetLogger() + logger.Info("Starting AVC format H.264 HTTP stream", "device", h.deviceSerial) + + // Set headers for AVC format H.264 streaming + w.Header().Set("Content-Type", "video/h264") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Format", "avc") // Custom header to indicate AVC format + + // Get or create scrcpy source with H.264 mode + source, err := scrcpy.StartSourceWithMode(h.deviceSerial, r.Context(), "h264") + if err != nil { + logger.Error("Failed to start scrcpy source", "device", h.deviceSerial, "error", err) + http.Error(w, fmt.Sprintf("Failed to start stream: %v", err), http.StatusInternalServerError) + return + } + + // Generate unique subscriber ID for this connection + subscriberID := fmt.Sprintf("avc_http_%d", time.Now().UnixNano()) + + // Subscribe to video stream + videoCh := source.SubscribeVideo(subscriberID, 100) + defer source.UnsubscribeVideo(subscriberID) + + // Send SPS/PPS first if available (convert to AVC format) + if spsPps := source.GetSpsPps(); len(spsPps) > 0 { + avcSpsPps, convertErr := h.converter.Convert(spsPps) + if convertErr != nil { + logger.Error("Failed to convert SPS/PPS to AVC format", "device", h.deviceSerial, "error", convertErr) + http.Error(w, "Failed to convert SPS/PPS", http.StatusInternalServerError) + return + } + + if len(avcSpsPps) > 0 { + if _, err := w.Write(avcSpsPps); err != nil { + logger.Error("Failed to write AVC SPS/PPS", "device", h.deviceSerial, "error", err) + return + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + logger.Info("Sent AVC SPS/PPS", "device", h.deviceSerial, "size", len(avcSpsPps)) + } + } + + // Stream video data + frameCount := 0 + for { + select { + case <-r.Context().Done(): + logger.Info("AVC HTTP stream context cancelled", "device", h.deviceSerial) + return + + case sample, ok := <-videoCh: + if !ok { + logger.Info("AVC HTTP video channel closed", "device", h.deviceSerial) + return + } + + frameCount++ + + // Convert H.264 Annex-B data to AVC format + avcData, convertErr := h.converter.Convert(sample.Data) + if convertErr != nil { + logger.Error("Failed to convert H.264 data to AVC format", + "device", h.deviceSerial, + "frame", frameCount, + "error", convertErr) + continue // Skip this frame but continue streaming + } + + // Write AVC format data + if len(avcData) > 0 { + if _, err := w.Write(avcData); err != nil { + logger.Error("Failed to write AVC data", + "device", h.deviceSerial, + "frame", frameCount, + "error", err) + return + } + + // Flush data immediately for low latency + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + // Log first few frames for debugging + if frameCount <= 5 { + logger.Info("Sent AVC frame", + "device", h.deviceSerial, + "frame", frameCount, + "originalSize", len(sample.Data), + "avcSize", len(avcData)) + } + } else { + logger.Warn("Empty AVC data after conversion", + "device", h.deviceSerial, + "frame", frameCount, + "originalSize", len(sample.Data)) + } + } + } +} diff --git a/packages/cli/internal/device_connect/transport/h264/handler_http.go b/packages/cli/internal/device_connect/transport/h264/handler_http.go new file mode 100644 index 00000000..7a24a400 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/handler_http.go @@ -0,0 +1,85 @@ +package h264 + +import ( + "fmt" + "net/http" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// HTTPHandler handles HTTP-based H.264 streaming +type HTTPHandler struct { + deviceSerial string +} + +// NewHTTPHandler creates a new HTTP handler for H.264 streaming +func NewHTTPHandler(deviceSerial string) *HTTPHandler { + return &HTTPHandler{ + deviceSerial: deviceSerial, + } +} + +// ServeHTTP implements http.Handler for direct H.264 streaming +func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + logger := util.GetLogger() + logger.Info("Starting H.264 HTTP stream", "device", h.deviceSerial) + + // Set headers for H.264 streaming + w.Header().Set("Content-Type", "video/h264") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + // Get or create scrcpy source with H.264 mode + source, err := scrcpy.StartSourceWithMode(h.deviceSerial, r.Context(), "h264") + if err != nil { + logger.Error("Failed to start scrcpy source", "device", h.deviceSerial, "error", err) + http.Error(w, fmt.Sprintf("Failed to start stream: %v", err), http.StatusInternalServerError) + return + } + + // Generate unique subscriber ID for this connection + subscriberID := fmt.Sprintf("h264_http_%d", time.Now().UnixNano()) + + // Subscribe to video stream + videoCh := source.SubscribeVideo(subscriberID, 100) + defer source.UnsubscribeVideo(subscriberID) + + // Send SPS/PPS first if available + if spsPps := source.GetSpsPps(); len(spsPps) > 0 { + if _, err := w.Write(spsPps); err != nil { + logger.Error("Failed to write SPS/PPS", "device", h.deviceSerial, "error", err) + return + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + + // Stream video data + for { + select { + case <-r.Context().Done(): + logger.Info("H.264 HTTP stream context cancelled", "device", h.deviceSerial) + return + + case sample, ok := <-videoCh: + if !ok { + logger.Info("H.264 HTTP video channel closed", "device", h.deviceSerial) + return + } + + // Write H.264 data directly + if _, err := w.Write(sample.Data); err != nil { + logger.Error("Failed to write H.264 data", "device", h.deviceSerial, "error", err) + return + } + + // Flush data immediately for low latency + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + } +} diff --git a/packages/cli/internal/device_connect/transport/h264/handler_ws.go b/packages/cli/internal/device_connect/transport/h264/handler_ws.go new file mode 100644 index 00000000..acaa920a --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/handler_ws.go @@ -0,0 +1,104 @@ +package h264 + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" + "github.com/babelcloud/gbox/packages/cli/internal/util" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins for development + }, +} + +// WSHandler handles WebSocket-based H.264 streaming +type WSHandler struct { + deviceSerial string +} + +// NewWSHandler creates a new WebSocket handler for H.264 streaming +func NewWSHandler(deviceSerial string) *WSHandler { + return &WSHandler{ + deviceSerial: deviceSerial, + } +} + +// ServeWebSocket handles WebSocket connections for H.264 streaming +func (h *WSHandler) ServeWebSocket(w http.ResponseWriter, r *http.Request) { + logger := util.GetLogger() + logger.Info("Starting H.264 WebSocket stream", "device", h.deviceSerial) + + // Upgrade to WebSocket + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + logger.Error("Failed to upgrade to WebSocket", "device", h.deviceSerial, "error", err) + return + } + defer conn.Close() + + // Get or create scrcpy source with H.264 mode + source, err := scrcpy.StartSourceWithMode(h.deviceSerial, r.Context(), "h264") + if err != nil { + logger.Error("Failed to start scrcpy source", "device", h.deviceSerial, "error", err) + conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("Failed to start stream: %v", err))) + return + } + + // Subscribe to video stream + videoCh := source.SubscribeVideo("h264_ws", 100) + defer source.UnsubscribeVideo("h264_ws") + + // Send SPS/PPS first if available + if spsPps := source.GetSpsPps(); len(spsPps) > 0 { + if err := conn.WriteMessage(websocket.BinaryMessage, spsPps); err != nil { + logger.Error("Failed to write SPS/PPS", "device", h.deviceSerial, "error", err) + return + } + } + + // Start goroutine to handle keyframe requests + go func() { + for { + messageType, data, err := conn.ReadMessage() + if err != nil { + logger.Debug("H.264 WebSocket read error", "device", h.deviceSerial, "error", err) + return + } + if messageType == websocket.TextMessage { + var msg map[string]interface{} + if err := json.Unmarshal(data, &msg); err == nil { + if msgType, ok := msg["type"].(string); ok && msgType == "request_keyframe" { + logger.Debug("Received keyframe request from H.264 client", "device", h.deviceSerial) + source.RequestKeyframe() + } + } + } + } + }() + + // Stream video data + for { + select { + case <-r.Context().Done(): + logger.Info("H.264 WebSocket stream context cancelled", "device", h.deviceSerial) + return + + case sample, ok := <-videoCh: + if !ok { + logger.Info("H.264 WebSocket video channel closed", "device", h.deviceSerial) + return + } + + // Send H.264 data as binary message + if err := conn.WriteMessage(websocket.BinaryMessage, sample.Data); err != nil { + logger.Error("Failed to write H.264 data", "device", h.deviceSerial, "error", err) + return + } + } + } +} diff --git a/packages/cli/internal/device_connect/transport/h264/nal.go b/packages/cli/internal/device_connect/transport/h264/nal.go new file mode 100644 index 00000000..c41aea09 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/nal.go @@ -0,0 +1,208 @@ +package h264 + +import ( + "bytes" +) + +var ( + // Standard Annex-B start codes + StartCode3 = []byte{0x00, 0x00, 0x01} + StartCode4 = []byte{0x00, 0x00, 0x00, 0x01} + + // AUD (Access Unit Delimiter) NAL unit - useful for some decoders + AUDNalUnit = []byte{0x00, 0x00, 0x00, 0x01, 0x09, 0x10} +) + +// NALUnitType represents H.264 NAL unit types +type NALUnitType uint8 + +const ( + NALUnitTypeSlice NALUnitType = 1 + NALUnitTypeDPA NALUnitType = 2 + NALUnitTypeDPB NALUnitType = 3 + NALUnitTypeDPC NALUnitType = 4 + NALUnitTypeIDR NALUnitType = 5 + NALUnitTypeSEI NALUnitType = 6 + NALUnitTypeSPS NALUnitType = 7 + NALUnitTypePPS NALUnitType = 8 + NALUnitTypeAUD NALUnitType = 9 + NALUnitTypeEndSeq NALUnitType = 10 + NALUnitTypeEndStream NALUnitType = 11 + NALUnitTypeFiller NALUnitType = 12 +) + +// GetNALUnitType extracts the NAL unit type from the first byte after start code +func GetNALUnitType(data []byte) (NALUnitType, bool) { + nalStart := FindStartCode(data) + if nalStart == -1 || nalStart+4 >= len(data) { + return 0, false + } + + // Skip start code and get NAL unit type from first 5 bits + nalByte := data[nalStart+3] // For 3-byte start code, +4 for 4-byte + if data[nalStart+1] == 0x00 && data[nalStart+2] == 0x00 && data[nalStart+3] == 0x01 { + // 4-byte start code + if nalStart+4 >= len(data) { + return 0, false + } + nalByte = data[nalStart+4] + } + + return NALUnitType(nalByte & 0x1F), true +} + +// FindStartCode locates the position of the first start code in data +func FindStartCode(data []byte) int { + if pos := bytes.Index(data, StartCode4); pos != -1 { + return pos + } + if pos := bytes.Index(data, StartCode3); pos != -1 { + return pos + } + return -1 +} + +// HasStartCode checks if data begins with a start code +func HasStartCode(data []byte) bool { + return bytes.HasPrefix(data, StartCode4) || bytes.HasPrefix(data, StartCode3) +} + +// AddStartCodeIfNeeded prepends a start code if the data doesn't already have one +func AddStartCodeIfNeeded(data []byte) []byte { + if HasStartCode(data) { + return data + } + + // Use 4-byte start code by default + result := make([]byte, 0, len(data)+4) + result = append(result, StartCode4...) + result = append(result, data...) + return result +} + +// ExtractSpsPpsAnnexB extracts SPS and PPS from Annex-B formatted data +// and returns them as separate NAL units with start codes +func ExtractSpsPpsAnnexB(data []byte) []byte { + if len(data) == 0 { + return nil + } + + // Add start code if needed + processedData := AddStartCodeIfNeeded(data) + + // Split by start codes to find individual NAL units + nalUnits := SplitByStartCodes(processedData) + + var result []byte + for _, nalUnit := range nalUnits { + if len(nalUnit) == 0 { + continue + } + + nalType, ok := GetNALUnitType(nalUnit) + if !ok { + continue + } + + // Include SPS and PPS NAL units + if nalType == NALUnitTypeSPS || nalType == NALUnitTypePPS { + result = append(result, nalUnit...) + } + } + + return result +} + +// SplitByStartCodes splits Annex-B data into individual NAL units, +// each retaining its start code +func SplitByStartCodes(data []byte) [][]byte { + if len(data) == 0 { + return nil + } + + var nalUnits [][]byte + var currentStart int + + // Find all start code positions + for i := 0; i < len(data)-2; { + // Look for 3-byte or 4-byte start codes + if i < len(data)-3 && bytes.Equal(data[i:i+4], StartCode4) { + // Found 4-byte start code + if i > currentStart { + nalUnits = append(nalUnits, data[currentStart:i]) + } + currentStart = i + i += 4 + } else if bytes.Equal(data[i:i+3], StartCode3) { + // Found 3-byte start code + if i > currentStart { + nalUnits = append(nalUnits, data[currentStart:i]) + } + currentStart = i + i += 3 + } else { + i++ + } + } + + // Add the last NAL unit + if currentStart < len(data) { + nalUnits = append(nalUnits, data[currentStart:]) + } + + return nalUnits +} + +// IsKeyFrame checks if the data contains an IDR (keyframe) NAL unit +func IsKeyFrame(data []byte) bool { + nalUnits := SplitByStartCodes(data) + for _, nalUnit := range nalUnits { + if nalType, ok := GetNALUnitType(nalUnit); ok && nalType == NALUnitTypeIDR { + return true + } + } + return false +} + +// PrependAUD adds an Access Unit Delimiter before the data. +// This can help some decoders properly parse frame boundaries. +func PrependAUD(data []byte) []byte { + result := make([]byte, 0, len(AUDNalUnit)+len(data)) + result = append(result, AUDNalUnit...) + result = append(result, data...) + return result +} + +// PrependSpsPps prepends SPS/PPS configuration data before keyframes. +// This ensures decoders have the necessary config data. +func PrependSpsPps(data []byte, spsPps []byte) []byte { + if len(spsPps) == 0 { + return data + } + + result := make([]byte, 0, len(spsPps)+len(data)) + result = append(result, spsPps...) + result = append(result, data...) + return result +} + +// StripEmulationPrevention removes emulation prevention bytes (0x03) +// from NAL unit data. This is sometimes needed for certain processing. +func StripEmulationPrevention(data []byte) []byte { + if len(data) < 3 { + return data + } + + var result []byte + for i := 0; i < len(data); { + if i+2 < len(data) && data[i] == 0x00 && data[i+1] == 0x00 && data[i+2] == 0x03 { + // Found emulation prevention sequence, skip the 0x03 byte + result = append(result, data[i], data[i+1]) + i += 3 + } else { + result = append(result, data[i]) + i++ + } + } + return result +} diff --git a/packages/cli/internal/device_connect/transport/h264/transport.go b/packages/cli/internal/device_connect/transport/h264/transport.go new file mode 100644 index 00000000..274b32ba --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/transport.go @@ -0,0 +1,71 @@ +package h264 + +import ( + "net/http" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" + "github.com/babelcloud/gbox/packages/cli/internal/util" + "github.com/gorilla/mux" +) + +// Global handlers - initialized when needed +var ( + httpHandler *HTTPHandler + wsHandler *WSHandler +) + +// ServeHTTP provides a package-level HTTP handler for H.264 streaming +func ServeHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := util.GetLogger() + + // Get or create scrcpy source with H.264 mode + _, err := scrcpy.StartSourceWithMode(deviceSerial, r.Context(), "h264") + if err != nil { + logger.Error("Failed to start scrcpy source", "device", deviceSerial, "error", err) + http.Error(w, "Failed to start device source", http.StatusInternalServerError) + return + } + + // Create handler if not exists + if httpHandler == nil { + httpHandler = NewHTTPHandler(deviceSerial) + } + + // Create a fake mux.Router just for this request to extract device_id + router := mux.NewRouter() + router.HandleFunc("/stream/video/{device_id}", httpHandler.ServeHTTP).Methods("GET") + + // Modify the request URL to include device_id + r.URL.Path = "/stream/video/" + deviceSerial + + // Serve the request + router.ServeHTTP(w, r) +} + +// ServeWS provides a package-level WebSocket handler for H.264 streaming +func ServeWS(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := util.GetLogger() + + // Get or create scrcpy source with H.264 mode + _, err := scrcpy.StartSourceWithMode(deviceSerial, r.Context(), "h264") + if err != nil { + logger.Error("Failed to start scrcpy source", "device", deviceSerial, "error", err) + http.Error(w, "Failed to start device source", http.StatusInternalServerError) + return + } + + // Create handler if not exists + if wsHandler == nil { + wsHandler = NewWSHandler(deviceSerial) + } + + // Create a fake mux.Router just for this request to extract device_id + router := mux.NewRouter() + router.HandleFunc("/ws/video/{device_id}", wsHandler.ServeWebSocket).Methods("GET") + + // Modify the request URL to include device_id + r.URL.Path = "/ws/video/" + deviceSerial + + // Serve the request + router.ServeHTTP(w, r) +} diff --git a/packages/cli/internal/device_connect/transport/mse/control.go b/packages/cli/internal/device_connect/transport/mse/control.go new file mode 100644 index 00000000..c92c77aa --- /dev/null +++ b/packages/cli/internal/device_connect/transport/mse/control.go @@ -0,0 +1,46 @@ +package mse + +import ( + "net" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/control" +) + +// ControlHandler wraps the shared control handler for MSE transport +type ControlHandler struct { + *control.Handler +} + +// NewControlHandler creates a new MSE control handler +func NewControlHandler(conn net.Conn, screenWidth, screenHeight int) *ControlHandler { + // Create shared control handler with connection (no DataChannel for MSE) + sharedHandler := control.NewHandler(conn, nil, screenWidth, screenHeight) + + return &ControlHandler{ + Handler: sharedHandler, + } +} + +// SetSource sets the scrcpy source for sending control messages +func (h *ControlHandler) SetSource(source core.Source) { + h.Handler.SetSource(source) +} + +// UpdateConnection updates the control connection +func (h *ControlHandler) UpdateConnection(conn net.Conn) { + h.Handler.UpdateConnection(conn) +} + +// UpdateScreenDimensions updates the screen dimensions +func (h *ControlHandler) UpdateScreenDimensions(width, height int) { + h.Handler.UpdateScreenDimensions(width, height) +} + +// HandleIncomingMessages handles incoming control messages +func (h *ControlHandler) HandleIncomingMessages() { + // For MSE, we don't have WebRTC DataChannel, so we handle messages differently + // This could be implemented to read from the connection directly + // For now, we'll use the shared handler's logic + h.Handler.HandleIncomingMessages() +} diff --git a/packages/cli/internal/device_connect/transport/mse/handler.go b/packages/cli/internal/device_connect/transport/mse/handler.go new file mode 100644 index 00000000..9f97b456 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/mse/handler.go @@ -0,0 +1,102 @@ +package mse + +import ( + "net/http" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/pipeline" + "github.com/babelcloud/gbox/packages/cli/internal/util" + "github.com/gorilla/mux" +) + +// Handler provides HTTP endpoints for MSE fMP4 streaming. +type Handler struct { + broadcaster *pipeline.Broadcaster +} + +// NewHandler creates a new MSE HTTP handler. +func NewHandler(broadcaster *pipeline.Broadcaster) *Handler { + return &Handler{ + broadcaster: broadcaster, + } +} + +// RegisterRoutes registers MSE-related routes with the given router. +func (h *Handler) RegisterRoutes(router *mux.Router) { + router.HandleFunc("/stream/video/{device_id}", h.handleVideoStream).Methods("GET") +} + +// handleVideoStream serves the fMP4 video stream for MSE consumption. +func (h *Handler) handleVideoStream(w http.ResponseWriter, r *http.Request) { + logger := util.GetLogger() + vars := mux.Vars(r) + deviceID := vars["device_id"] + + if deviceID == "" { + http.Error(w, "Device ID required", http.StatusBadRequest) + return + } + + // Check if MSE mode is requested + mode := r.URL.Query().Get("mode") + if mode != "mse" { + http.Error(w, "MSE mode required", http.StatusBadRequest) + return + } + + // Set appropriate headers for fMP4 streaming + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Range") + + // Generate unique subscriber ID for this request + subscriberID := util.GenerateRandomString(8) + "_mse_" + deviceID + + // Subscribe to the broadcaster + dataCh := h.broadcaster.Subscribe(subscriberID, 50) // Buffer 50 chunks + defer h.broadcaster.Unsubscribe(subscriberID) + + logger.Info("MSE video stream started", "device", deviceID, "subscriber", subscriberID, "remote", r.RemoteAddr) + + // Set up cleanup on client disconnect + notify := w.(http.CloseNotifier).CloseNotify() + + // Stream data to client + for { + select { + case data, ok := <-dataCh: + if !ok { + logger.Info("MSE data channel closed", "device", deviceID, "subscriber", subscriberID) + return + } + + // Write data to response + if _, err := w.Write(data); err != nil { + logger.Warn("Failed to write to MSE client", "device", deviceID, "subscriber", subscriberID, "error", err) + return + } + + // Flush data immediately for low latency + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + + case <-notify: + logger.Info("MSE client disconnected", "device", deviceID, "subscriber", subscriberID) + return + + case <-r.Context().Done(): + logger.Info("MSE request context cancelled", "device", deviceID, "subscriber", subscriberID) + return + + case <-time.After(30 * time.Second): + // Timeout if no data for 30 seconds + logger.Warn("MSE stream timeout", "device", deviceID, "subscriber", subscriberID) + return + } + } +} diff --git a/packages/cli/internal/device_connect/transport/mse/packager_ffmpeg.go b/packages/cli/internal/device_connect/transport/mse/packager_ffmpeg.go new file mode 100644 index 00000000..eb8fc091 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/mse/packager_ffmpeg.go @@ -0,0 +1,186 @@ +package mse + +import ( + "context" + "io" + "os/exec" + "syscall" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/pipeline" + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// FFmpegPackager handles H.264 to fMP4 packaging using FFmpeg. +// It subscribes to a pipeline and outputs fMP4 data to a broadcaster. +type FFmpegPackager struct { + ctx context.Context + cancel context.CancelFunc + pipeline *pipeline.Pipeline + broadcaster *pipeline.Broadcaster + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + running bool +} + +// NewFFmpegPackager creates a new FFmpeg-based H.264 to fMP4 packager. +func NewFFmpegPackager(p *pipeline.Pipeline, broadcaster *pipeline.Broadcaster) *FFmpegPackager { + ctx, cancel := context.WithCancel(context.Background()) + return &FFmpegPackager{ + ctx: ctx, + cancel: cancel, + pipeline: p, + broadcaster: broadcaster, + } +} + +// Start begins the FFmpeg packaging process. +func (f *FFmpegPackager) Start() error { + logger := util.GetLogger() + + if f.running { + return nil + } + + // Subscribe to the pipeline for video samples + videoCh := f.pipeline.SubscribeVideo("mse_packager", 100) + + // Create FFmpeg command for H.264 to fMP4 conversion + f.cmd = exec.CommandContext(f.ctx, "ffmpeg", + "-f", "h264", // Input format: raw H.264 + "-i", "pipe:0", // Read from stdin + "-c:v", "copy", // Copy video without re-encoding + "-f", "mp4", // Output format: MP4 + "-movflags", "frag_keyframe+empty_moov+default_base_moof", // fMP4 flags + "-fflags", "+genpts", // Generate presentation timestamps + "-video_track_timescale", "1000", // Stable timescale + "-reset_timestamps", "1", // Reset timestamps to start from 0 + "pipe:1", // Write to stdout + ) + + // Get stdin and stdout pipes + stdin, err := f.cmd.StdinPipe() + if err != nil { + return err + } + f.stdin = stdin + + stdout, err := f.cmd.StdoutPipe() + if err != nil { + f.stdin.Close() + return err + } + f.stdout = stdout + + // Start FFmpeg process + if err := f.cmd.Start(); err != nil { + f.stdin.Close() + f.stdout.Close() + return err + } + + f.running = true + logger.Info("FFmpeg MSE packager started", "pid", f.cmd.Process.Pid) + + // Start goroutines for input and output handling + go f.feedInputLoop(videoCh) + go f.readOutputLoop() + + return nil +} + +// Stop stops the FFmpeg packaging process. +func (f *FFmpegPackager) Stop() error { + logger := util.GetLogger() + + if !f.running { + return nil + } + + f.running = false + f.cancel() + + // Close stdin to signal FFmpeg to finish + if f.stdin != nil { + f.stdin.Close() + } + + // Wait for process to finish or kill it + if f.cmd != nil && f.cmd.Process != nil { + done := make(chan error, 1) + go func() { + done <- f.cmd.Wait() + }() + + select { + case err := <-done: + logger.Info("FFmpeg MSE packager stopped", "error", err) + case <-f.ctx.Done(): + // Force kill if it doesn't stop gracefully + f.cmd.Process.Signal(syscall.SIGTERM) + select { + case <-done: + default: + f.cmd.Process.Kill() + } + logger.Warn("FFmpeg MSE packager force killed") + } + } + + // Close stdout + if f.stdout != nil { + f.stdout.Close() + } + + return nil +} + +// feedInputLoop reads video samples from the pipeline and feeds them to FFmpeg. +func (f *FFmpegPackager) feedInputLoop(videoCh <-chan core.VideoSample) { + logger := util.GetLogger() + defer logger.Info("FFmpeg input feed loop stopped") + + for { + select { + case <-f.ctx.Done(): + return + case sample, ok := <-videoCh: + if !ok { + logger.Info("Video channel closed, stopping input feed") + return + } + + if len(sample.Data) > 0 && f.stdin != nil { + _, err := f.stdin.Write(sample.Data) + if err != nil { + logger.Error("Failed to write to FFmpeg stdin", "error", err) + return + } + } + } + } +} + +// readOutputLoop reads fMP4 data from FFmpeg and sends it to the broadcaster. +func (f *FFmpegPackager) readOutputLoop() { + logger := util.GetLogger() + defer logger.Info("FFmpeg output read loop stopped") + + // Try to detect and cache the initialization segment + if initSegment, remainingReader, err := pipeline.DetectInitSegmentFromStream(f.stdout); err == nil { + if len(initSegment) > 0 { + f.broadcaster.SetInitSegment(initSegment) + logger.Info("fMP4 initialization segment cached", "size", len(initSegment)) + } + + // Continue reading and broadcasting the remaining data + if remainingReader != nil { + pipeline.StreamToBroadcaster(remainingReader, f.broadcaster, 4096) + } + } else { + logger.Error("Failed to detect init segment", "error", err) + // Fallback: just stream everything to broadcaster + pipeline.StreamToBroadcaster(f.stdout, f.broadcaster, 4096) + } +} diff --git a/packages/cli/internal/device_connect/transport/mse/transport.go b/packages/cli/internal/device_connect/transport/mse/transport.go new file mode 100644 index 00000000..d2e0e878 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/mse/transport.go @@ -0,0 +1,47 @@ +package mse + +import ( + "fmt" + "net/http" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// Transport implements MSE streaming transport +type Transport struct { + deviceSerial string +} + +// NewTransport creates a new MSE transport +func NewTransport(deviceSerial string) *Transport { + return &Transport{ + deviceSerial: deviceSerial, + } +} + +// ServeHTTP implements http.Handler for MSE fMP4 streaming +func (t *Transport) ServeHTTP(w http.ResponseWriter, r *http.Request) { + logger := util.GetLogger() + logger.Info("Starting MSE stream", "device", t.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("Access-Control-Allow-Origin", "*") + + // Get or create scrcpy source (for future implementation) + _, err := scrcpy.StartSource(t.deviceSerial, r.Context()) + if err != nil { + logger.Error("Failed to start scrcpy source", "device", t.deviceSerial, "error", err) + http.Error(w, fmt.Sprintf("Failed to start stream: %v", err), http.StatusInternalServerError) + return + } + + // For now, return a placeholder response indicating MSE is being developed + w.WriteHeader(http.StatusNotImplemented) + w.Write([]byte("MSE mode is currently being refactored. Please use WebRTC or H.264 mode.")) + + logger.Info("MSE stream request handled", "device", t.deviceSerial) +} diff --git a/packages/cli/internal/device_connect/transport/webrtc/bridge.go b/packages/cli/internal/device_connect/transport/webrtc/bridge.go new file mode 100644 index 00000000..37cad536 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/webrtc/bridge.go @@ -0,0 +1,111 @@ +package webrtc + +import ( + "context" + "fmt" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" + "github.com/pion/webrtc/v4" +) + +// Bridge provides backward compatibility with the old WebRTC Bridge interface +// This adapter wraps the new Transport implementation +type Bridge struct { + transport *Transport + source *scrcpy.Source + + + // Backward compatibility fields + DeviceSerial string + VideoWidth int + VideoHeight int + WebRTCConn *webrtc.PeerConnection + DataChannel *webrtc.DataChannel + WSConnection interface{} // For WebSocket connection compatibility +} + +// NewBridge creates a new WebRTC bridge for a device (backward compatibility) +func NewBridge(deviceSerial string, adbPath string) (*Bridge, error) { + + // Start scrcpy source + src, err := scrcpy.StartSource(deviceSerial, context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to start scrcpy source: %w", err) + } + + // Create new transport (pass nil pipeline for now) + transport, err := NewTransport(deviceSerial, nil) + if err != nil { + return nil, fmt.Errorf("failed to create WebRTC transport: %w", err) + } + + // Get device info + deviceSerial, videoWidth, videoHeight := src.GetConnectionInfo() + + return &Bridge{ + transport: transport, + source: src, + DeviceSerial: deviceSerial, + VideoWidth: videoWidth, + VideoHeight: videoHeight, + WebRTCConn: transport.GetPeerConnection(), + DataChannel: nil, // Will be set when received + }, nil +} + +// Start starts the bridge connection to device +func (b *Bridge) Start() error { + return b.transport.Start(b.source) +} + +// Close closes the bridge and all its connections +func (b *Bridge) Close() error { + if b.transport != nil { + b.transport.Close() + } + // Clean up the scrcpy source + if b.source != nil { + b.source.Stop() + // Remove from global manager to ensure clean state for reconnection + scrcpy.RemoveSource(b.DeviceSerial) + } + return nil +} + +// GetPeerConnection returns the WebRTC peer connection for signaling +func (b *Bridge) GetPeerConnection() *webrtc.PeerConnection { + return b.transport.GetPeerConnection() +} + +// Backward compatibility methods that delegate to transport +func (b *Bridge) SendControlMessage(msg *device.ControlMessage) error { + // This is now handled by ControlHandler in the transport + return nil +} + +func (b *Bridge) HandleTouchEvent(message map[string]interface{}) { + // This is now handled by ControlHandler in the transport +} + +func (b *Bridge) HandleKeyEvent(message map[string]interface{}) { + // This is now handled by ControlHandler in the transport +} + +func (b *Bridge) HandleScrollEvent(message map[string]interface{}) { + // This is now handled by ControlHandler in the transport +} + +// Additional getters for backward compatibility +func (b *Bridge) GetDeviceSerial() string { + return b.transport.deviceSerial +} + +func (b *Bridge) GetVideoTrack() interface{} { + return b.transport.videoTrack +} + +func (b *Bridge) GetAudioTrack() interface{} { + return b.transport.audioTrack +} + diff --git a/packages/cli/internal/device_connect/transport/webrtc/control_wrapper.go b/packages/cli/internal/device_connect/transport/webrtc/control_wrapper.go new file mode 100644 index 00000000..5c27c350 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/webrtc/control_wrapper.go @@ -0,0 +1,49 @@ +package webrtc + +import ( + "net" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/control" + "github.com/pion/webrtc/v4" +) + +// ControlHandlerWrapper wraps the shared control handler for WebRTC transport +type ControlHandlerWrapper struct { + *control.Handler +} + +// NewControlHandlerWrapper creates a new WebRTC control handler wrapper +func NewControlHandlerWrapper(dataChannel *webrtc.DataChannel, screenWidth, screenHeight int) *ControlHandlerWrapper { + // Create shared control handler with DataChannel (no connection for WebRTC) + sharedHandler := control.NewHandler(nil, dataChannel, screenWidth, screenHeight) + + return &ControlHandlerWrapper{ + Handler: sharedHandler, + } +} + +// SetSource sets the scrcpy source for sending control messages +func (h *ControlHandlerWrapper) SetSource(source core.Source) { + h.Handler.SetSource(source) +} + +// UpdateDataChannel updates the WebRTC data channel +func (h *ControlHandlerWrapper) UpdateDataChannel(dataChannel *webrtc.DataChannel) { + h.Handler.UpdateDataChannel(dataChannel) +} + +// UpdateConnection updates the control connection (not used for WebRTC) +func (h *ControlHandlerWrapper) UpdateConnection(conn net.Conn) { + h.Handler.UpdateConnection(conn) +} + +// UpdateScreenDimensions updates the screen dimensions +func (h *ControlHandlerWrapper) UpdateScreenDimensions(width, height int) { + h.Handler.UpdateScreenDimensions(width, height) +} + +// HandleIncomingMessages handles incoming control messages +func (h *ControlHandlerWrapper) HandleIncomingMessages() { + h.Handler.HandleIncomingMessages() +} diff --git a/packages/cli/internal/device_connect/transport/webrtc/manager.go b/packages/cli/internal/device_connect/transport/webrtc/manager.go new file mode 100644 index 00000000..159b24dc --- /dev/null +++ b/packages/cli/internal/device_connect/transport/webrtc/manager.go @@ -0,0 +1,118 @@ +package webrtc + +import ( + "fmt" + "sync" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/util" + "github.com/pion/webrtc/v4" +) + +// Manager manages WebRTC bridges for multiple devices +// This replaces the old separate webrtc.Manager +type Manager struct { + bridges map[string]*Bridge // deviceSerial -> bridge + mu sync.RWMutex + adbPath string +} + +// NewManager creates a new unified bridge manager +func NewManager(adbPath string) *Manager { + return &Manager{ + bridges: make(map[string]*Bridge), + adbPath: adbPath, + } +} + +// CreateBridge creates a new WebRTC bridge for a device +func (m *Manager) CreateBridge(deviceSerial string) (*Bridge, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if bridge already exists and is in a valid state + if existing := m.bridges[deviceSerial]; existing != nil { + if pc := existing.GetPeerConnection(); pc != nil { + state := pc.ConnectionState() + if state != webrtc.PeerConnectionStateClosed && state != webrtc.PeerConnectionStateFailed && state != webrtc.PeerConnectionStateDisconnected { + logger := util.GetLogger() + logger.Info("Reusing existing WebRTC bridge", "device", deviceSerial, "state", state.String()) + return existing, nil + } + // Connection is closed/failed/disconnected, remove and recreate + logger := util.GetLogger() + logger.Info("Removing invalid WebRTC bridge for recreation", "device", deviceSerial, "state", state.String()) + existing.Close() + delete(m.bridges, deviceSerial) + + // Add longer delay for ICE connection cleanup + time.Sleep(500 * time.Millisecond) + } + } + + // Create new WebRTC bridge + bridge, err := NewBridge(deviceSerial, m.adbPath) + if err != nil { + return nil, fmt.Errorf("failed to create WebRTC bridge: %w", err) + } + + // Start the bridge + if err := bridge.Start(); err != nil { + bridge.Close() + return nil, fmt.Errorf("failed to start WebRTC bridge: %w", err) + } + + m.bridges[deviceSerial] = bridge + + logger := util.GetLogger() + logger.Info("WebRTC bridge created", "device", deviceSerial) + + return bridge, nil +} + +// GetBridge returns an existing bridge for a device +func (m *Manager) GetBridge(deviceSerial string) (*Bridge, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + bridge, exists := m.bridges[deviceSerial] + return bridge, exists +} + +// RemoveBridge removes and closes a bridge for a device +func (m *Manager) RemoveBridge(deviceSerial string) { + m.mu.Lock() + defer m.mu.Unlock() + + if bridge, exists := m.bridges[deviceSerial]; exists { + bridge.Close() + delete(m.bridges, deviceSerial) + + logger := util.GetLogger() + logger.Info("WebRTC bridge removed", "device", deviceSerial) + } +} + +// Close closes all bridges and shuts down the manager +func (m *Manager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + for deviceSerial, bridge := range m.bridges { + bridge.Close() + delete(m.bridges, deviceSerial) + } + + return nil +} + +// ListBridges returns all active bridge device serials +func (m *Manager) ListBridges() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + var devices []string + for deviceSerial := range m.bridges { + devices = append(devices, deviceSerial) + } + return devices +} diff --git a/packages/cli/internal/device_connect/webrtc/peer_connection.go b/packages/cli/internal/device_connect/transport/webrtc/peer_connection.go similarity index 69% rename from packages/cli/internal/device_connect/webrtc/peer_connection.go rename to packages/cli/internal/device_connect/transport/webrtc/peer_connection.go index b3d48607..998f5bb8 100644 --- a/packages/cli/internal/device_connect/webrtc/peer_connection.go +++ b/packages/cli/internal/device_connect/transport/webrtc/peer_connection.go @@ -16,17 +16,17 @@ type PeerConnectionConfig struct { AudioCodec string } -// CreatePeerConnection creates a new WebRTC peer connection -func CreatePeerConnection() (*webrtc.PeerConnection, error) { +// createPeerConnection creates a new WebRTC peer connection +func createPeerConnection() (*webrtc.PeerConnection, error) { // Create a MediaEngine with codecs m := &webrtc.MediaEngine{} - + // Register video codecs if err := m.RegisterCodec(webrtc.RTPCodecParameters{ RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, - ClockRate: 90000, - SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", }, PayloadType: 96, }, webrtc.RTPCodecTypeVideo); err != nil { @@ -48,9 +48,11 @@ func CreatePeerConnection() (*webrtc.PeerConnection, error) { // Create the API with MediaEngine api := webrtc.NewAPI(webrtc.WithMediaEngine(m)) - // Create a new RTCPeerConnection with low latency configuration + // Create a new RTCPeerConnection with configuration optimized for reconnection config := webrtc.Configuration{ ICEServers: []webrtc.ICEServer{}, + // Add aggressive ICE restart configuration + ICECandidatePoolSize: 1, } pc, err := api.NewPeerConnection(config) @@ -67,24 +69,26 @@ func CreatePeerConnection() (*webrtc.PeerConnection, error) { }) pc.OnICEConnectionStateChange(func(s webrtc.ICEConnectionState) { - // Only log important state changes or when verbose - if util.IsVerbose() || s == webrtc.ICEConnectionStateConnected || s == webrtc.ICEConnectionStateFailed || s == webrtc.ICEConnectionStateDisconnected { - log.Printf("ICE Connection State: %s", s.String()) - } + // Always log ICE connection state changes for debugging + log.Printf("ICE Connection State: %s", s.String()) }) return pc, nil } -// AddVideoTrack adds a video track to the peer connection -func AddVideoTrack(pc *webrtc.PeerConnection, codecType string) (*webrtc.TrackLocalStaticSample, error) { +// addVideoTrack adds a video track to the peer connection +func addVideoTrack(pc *webrtc.PeerConnection, codecType string) (*webrtc.TrackLocalStaticSample, error) { var videoTrack *webrtc.TrackLocalStaticSample var err error switch codecType { case "h264": videoTrack, err = webrtc.NewTrackLocalStaticSample( - webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264}, + webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", + }, "video", "android-screen", ) @@ -100,19 +104,24 @@ func AddVideoTrack(pc *webrtc.PeerConnection, codecType string) (*webrtc.TrackLo return nil, fmt.Errorf("failed to add video track: %w", err) } - log.Printf("Added %s video track", codecType) + // Video track added successfully (log.Printf can be uncommented for debugging) + // log.Printf("Added %s video track", codecType) return videoTrack, nil } -// AddAudioTrack adds an audio track to the peer connection -func AddAudioTrack(pc *webrtc.PeerConnection, codecType string) (*webrtc.TrackLocalStaticSample, error) { +// addAudioTrack adds an audio track to the peer connection +func addAudioTrack(pc *webrtc.PeerConnection, codecType string) (*webrtc.TrackLocalStaticSample, error) { var audioTrack *webrtc.TrackLocalStaticSample var err error switch codecType { case "opus": audioTrack, err = webrtc.NewTrackLocalStaticSample( - webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, + webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, + ClockRate: 48000, + Channels: 2, + }, "audio", "android-audio", ) @@ -128,7 +137,8 @@ func AddAudioTrack(pc *webrtc.PeerConnection, codecType string) (*webrtc.TrackLo return nil, fmt.Errorf("failed to add audio track: %w", err) } - log.Printf("Added %s audio track", codecType) + // Audio track added successfully (log.Printf can be uncommented for debugging) + // log.Printf("Added %s audio track", codecType) return audioTrack, nil } @@ -138,6 +148,6 @@ func WriteSample(track *webrtc.TrackLocalStaticSample, data []byte, duration uin Data: data, Duration: time.Duration(duration) * time.Nanosecond, } - + return track.WriteSample(sample) -} \ No newline at end of file +} diff --git a/packages/cli/internal/device_connect/transport/webrtc/transport.go b/packages/cli/internal/device_connect/transport/webrtc/transport.go new file mode 100644 index 00000000..33f18916 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/webrtc/transport.go @@ -0,0 +1,278 @@ +package webrtc + +import ( + "bytes" + "context" + "fmt" + "log" + "sync" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/pipeline" + "github.com/babelcloud/gbox/packages/cli/internal/util" + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media" +) + +// Transport implements WebRTC streaming transport +type Transport struct { + deviceSerial string + pipeline *pipeline.Pipeline + peerConnection *webrtc.PeerConnection + dataChannel *webrtc.DataChannel + + // Tracks + videoTrack *webrtc.TrackLocalStaticSample + audioTrack *webrtc.TrackLocalStaticSample + + // Control handler + controlHandler *ControlHandlerWrapper + + + // Control flow + ctx context.Context + cancel context.CancelFunc + + // Synchronization + mu sync.Mutex + closed bool +} + +// NewTransport creates a new WebRTC transport +func NewTransport(deviceSerial string, pipeline *pipeline.Pipeline) (*Transport, error) { + log.Printf("Creating WebRTC transport for device: %s", deviceSerial) + ctx, cancel := context.WithCancel(context.Background()) + + // Create WebRTC peer connection + pc, err := createPeerConnection() + if err != nil { + cancel() + return nil, fmt.Errorf("failed to create peer connection: %w", err) + } + + // Create transport + transport := &Transport{ + deviceSerial: deviceSerial, + pipeline: pipeline, + peerConnection: pc, + ctx: ctx, + cancel: cancel, + } + + // Set up data channel receiver (frontend will create the data channel) + pc.OnDataChannel(func(dc *webrtc.DataChannel) { + if dc.Label() == "control" { + transport.dataChannel = dc + log.Printf("Control DataChannel connected") + // Set up control handler when DataChannel is received + if transport.controlHandler != nil { + transport.controlHandler.UpdateDataChannel(dc) + transport.controlHandler.HandleIncomingMessages() + } + } + }) + + // Create control handler (DataChannel will be assigned when received) + transport.controlHandler = NewControlHandlerWrapper(nil, 1080, 1920) + + // Pre-create video and audio tracks for WebRTC negotiation + videoTrack, err := addVideoTrack(pc, "h264") + if err != nil { + pc.Close() + cancel() + return nil, fmt.Errorf("failed to add video track: %w", err) + } + transport.videoTrack = videoTrack + // H.264 video track configured + + // Add audio track + audioTrack, err := addAudioTrack(pc, "opus") + if err != nil { + pc.Close() + cancel() + return nil, fmt.Errorf("failed to add audio track: %w", err) + } + transport.audioTrack = audioTrack + // Opus audio track configured + + return transport, nil +} + + +// Start starts the WebRTC transport using pipeline +func (t *Transport) Start(source core.Source) error { + // Video: forward Annex-B samples to WebRTC video track + go func() { + videoCh := source.SubscribeVideo("webrtc_transport", 1000) + defer source.UnsubscribeVideo("webrtc_transport") + + var lastVideoTimestamp int64 = 0 + var h264Sps []byte + var h264Pps []byte + startCode := []byte{0x00, 0x00, 0x00, 0x01} + decoderReady := false + firstFrameSent := false + + for sample := range videoCh { + if sample.Data == nil || len(sample.Data) == 0 || t.videoTrack == nil { + continue + } + + // Calculate duration between frames + timestamp := sample.PTS + var duration time.Duration + if lastVideoTimestamp > 0 && timestamp > lastVideoTimestamp { + duration = time.Duration(timestamp-lastVideoTimestamp) * time.Microsecond + duration = min(duration, 33*time.Millisecond) // Cap at 30 FPS + } + lastVideoTimestamp = timestamp + + // Initialize SPS/PPS from cached data if not done yet + if len(h264Sps) == 0 || len(h264Pps) == 0 { + spsPpsData := source.GetSpsPps() + if len(spsPpsData) > 0 { + parts := bytes.Split(spsPpsData, startCode) + for i := 1; i < len(parts); i++ { + nal := parts[i] + if len(nal) == 0 { + continue + } + nalType := nal[0] & 0x1F + switch nalType { + case 7: // SPS + h264Sps = append([]byte{0x00, 0x00, 0x00, 0x01}, nal...) + case 8: // PPS + h264Pps = append([]byte{0x00, 0x00, 0x00, 0x01}, nal...) + } + } + } + } + + // For keyframes, send SPS/PPS first + if sample.IsKey && len(h264Sps) > 0 && len(h264Pps) > 0 { + t.videoTrack.WriteSample(media.Sample{Data: h264Sps, Duration: 0}) + t.videoTrack.WriteSample(media.Sample{Data: h264Pps, Duration: 0}) + decoderReady = true + } + + // Decide whether to send frame + shouldSendFrame := false + if sample.IsKey { + shouldSendFrame = true + if !firstFrameSent { + firstFrameSent = true + decoderReady = true + } + } else if decoderReady { + shouldSendFrame = true + } + + if shouldSendFrame { + frameSample := media.Sample{ + Data: sample.Data, + Duration: duration, + } + if err := t.videoTrack.WriteSample(frameSample); err != nil { + log.Printf("Failed to write video sample: %v", err) + return + } + } + } + }() + + // Audio: forward Opus packets as 20ms samples + go func() { + audioCh := source.SubscribeAudio("webrtc_transport", 100) + defer source.UnsubscribeAudio("webrtc_transport") + log.Printf("WebRTC audio processing started for device: %s", t.deviceSerial) + + sampleCount := 0 + for sample := range audioCh { + if sample.Data == nil || len(sample.Data) == 0 || t.audioTrack == nil { + continue + } + + sampleCount++ + // Log every 5000th audio sample for debugging (roughly every 100 seconds) and only in verbose mode + if sampleCount%5000 == 0 { + logger := util.GetLogger() + logger.Debug("WebRTC audio samples processed", "count", sampleCount) + } + + if err := t.audioTrack.WriteSample(media.Sample{Data: sample.Data, Duration: 20 * time.Millisecond}); err != nil { + log.Printf("Failed to write audio sample: %v", err) + return + } + } + log.Printf("WebRTC audio processing stopped for device: %s", t.deviceSerial) + }() + + // Control: handle control messages via the core.Source interface + if t.controlHandler != nil { + // Set the source for control message sending + t.controlHandler.SetSource(source) + + // Update screen dimensions from source (with retry if not available yet) + _, width, height := source.GetConnectionInfo() + if width == 0 || height == 0 { + // Screen dimensions not available yet, will retry + // Start a goroutine to update dimensions when available + go func() { + for i := 0; i < 10; i++ { + time.Sleep(500 * time.Millisecond) + _, w, h := source.GetConnectionInfo() + if w > 0 && h > 0 { + t.controlHandler.UpdateScreenDimensions(w, h) + // Screen dimensions updated + return + } + } + log.Printf("Failed to get screen dimensions after retries") + }() + } else { + t.controlHandler.UpdateScreenDimensions(width, height) + log.Printf("Control handler configured with source and screen dimensions: %dx%d", width, height) + } + } + + return nil +} + +// GetPeerConnection returns the WebRTC peer connection +func (t *Transport) GetPeerConnection() *webrtc.PeerConnection { + return t.peerConnection +} + +// Close closes the transport and all its connections +func (t *Transport) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + + if t.closed { + return nil + } + t.closed = true + + // Cancel context + if t.cancel != nil { + t.cancel() + } + + // Close WebRTC connection + if t.peerConnection != nil { + t.peerConnection.Close() + } + + log.Printf("WebRTC transport closed for device: %s", t.deviceSerial) + return nil +} + + +// min returns the minimum of two durations +func min(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} diff --git a/packages/cli/internal/device_connect/webrtc/bridge.go b/packages/cli/internal/device_connect/webrtc/bridge.go deleted file mode 100644 index 09f85607..00000000 --- a/packages/cli/internal/device_connect/webrtc/bridge.go +++ /dev/null @@ -1,837 +0,0 @@ -package webrtc - -import ( - "bytes" - "context" - "encoding/binary" - "fmt" - "io" - "log" - "net" - "sync" - "time" - - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/protocol" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/stream" - "github.com/babelcloud/gbox/packages/cli/internal/util" - "github.com/pion/webrtc/v4" - "github.com/pion/webrtc/v4/pkg/media" -) - -// Bridge bridges WebRTC connection with Android device streams -type Bridge struct { - DeviceSerial string - WebRTCConn *webrtc.PeerConnection - DataChannel *webrtc.DataChannel - WSConnection interface{} // WebSocket connection for sending info - - // Tracks - VideoTrack *webrtc.TrackLocalStaticSample - AudioTrack *webrtc.TrackLocalStaticSample - - // Device connections - scrcpyConn *device.ScrcpyConnection - - // Stream connections - videoConn net.Conn - audioConn net.Conn - controlConn net.Conn - - // Control handler - controlHandler *stream.ControlHandler - - // Video dimensions - VideoWidth int - VideoHeight int - - // Control flow - controlReady chan struct{} - controlMutex sync.Mutex // Protect controlReady channel operations - Context context.Context - cancel context.CancelFunc - - // Synchronization - mu sync.Mutex - closed bool -} - -// NewBridge creates a new WebRTC bridge for a device -func NewBridge(deviceSerial string, adbPath string) (*Bridge, error) { - log.Printf("NewBridge called for device: %s", deviceSerial) - ctx, cancel := context.WithCancel(context.Background()) - - // Create WebRTC peer connection - pc, err := CreatePeerConnection() - if err != nil { - cancel() - return nil, fmt.Errorf("failed to create peer connection: %w", err) - } - - // Create bridge - bridge := &Bridge{ - DeviceSerial: deviceSerial, - WebRTCConn: pc, - Context: ctx, - cancel: cancel, - controlReady: make(chan struct{}), - VideoWidth: 720, // Default dimensions - VideoHeight: 1280, - } - - // Set up data channel receiver (frontend will create the data channel) - pc.OnDataChannel(func(dc *webrtc.DataChannel) { - log.Printf("Received DataChannel: %s", dc.Label()) - if dc.Label() == "control" { - bridge.DataChannel = dc - log.Printf("Control DataChannel received and assigned") - // Set up control handler when DataChannel is received - if bridge.controlHandler != nil { - log.Printf("Setting up control handler with received DataChannel") - bridge.controlHandler.UpdateDataChannel(dc) - bridge.controlHandler.HandleIncomingMessages() - } - } - }) - - // Set up data channel handlers - bridge.setupDataChannelHandlers() - - // Pre-create control handler with nil connection and nil DataChannel (will be updated when DataChannel is received) - log.Printf("Creating ControlHandler with nil DataChannel (will be updated when received)") - bridge.controlHandler = stream.NewControlHandler(nil, nil, 1080, 1920) - log.Printf("ControlHandler created successfully") - // HandleIncomingMessages will be called when DataChannel is received - - // Pre-create video and audio tracks for WebRTC negotiation - // Default to H264 video track - videoTrack, err := AddVideoTrack(pc, "h264") - if err != nil { - pc.Close() - cancel() - return nil, fmt.Errorf("failed to add video track: %w", err) - } - bridge.VideoTrack = videoTrack - - // Add audio track - audioTrack, err := AddAudioTrack(pc, "opus") - if err != nil { - pc.Close() - cancel() - return nil, fmt.Errorf("failed to add audio track: %w", err) - } - bridge.AudioTrack = audioTrack - - return bridge, nil -} - -// Start starts the bridge connection to device -func (b *Bridge) Start() error { - // Generate SCID for this connection - use a valid port range (27183-37183) - // This gives us 10000 possible ports for concurrent connections - scid := uint32(27183 + (time.Now().UnixNano() % 10000)) - - // Create scrcpy connection - b.scrcpyConn = device.NewScrcpyConnection(b.DeviceSerial, scid) - - // Connect to scrcpy server - conn, err := b.scrcpyConn.Connect() - if err != nil { - return fmt.Errorf("failed to connect to scrcpy: %w", err) - } - - // Store the connection - b.videoConn = conn - - // Start media streaming from the first connection - go b.startMediaStreaming(conn) - - // Accept additional stream connections (audio, control) - if b.scrcpyConn.Listener != nil { - go b.acceptStreamConnections(b.scrcpyConn.Listener) - } - - return nil -} - -// acceptStreamConnections accepts incoming stream connections from device -func (b *Bridge) acceptStreamConnections(listener net.Listener) { - connectionCount := 1 // Start from 1 since video connection is already handled - - for { - select { - case <-b.Context.Done(): - return - default: - conn, err := listener.Accept() - if err != nil { - select { - case <-b.Context.Done(): - return - default: - log.Printf("Failed to accept stream connection: %v", err) - continue - } - } - - connectionCount++ - log.Printf("Accepted stream connection #%d", connectionCount) - - go b.handleStreamConnection(conn) - } - } -} - -// Codec IDs are imported from protocol package - -// handleStreamConnection handles an incoming stream connection -func (b *Bridge) handleStreamConnection(conn net.Conn) { - defer conn.Close() - - // Set timeout for codec ID reading - conn.SetReadDeadline(time.Now().Add(3 * time.Second)) - - // Read codec ID - codecIDBytes := make([]byte, 4) - n, err := io.ReadFull(conn, codecIDBytes) - if err != nil { - if err == io.EOF && n == 0 { - log.Println("Connection closed immediately, treating as control") - b.handleControlStream(conn) - return - } - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - log.Println("No codec ID received, treating as control") - b.handleControlStream(conn) - return - } - log.Printf("Failed to read codec ID: %v (read %d bytes)", err, n) - // If we read some bytes but not enough, it might be a partial codec ID - if n > 0 { - log.Printf("Partial codec ID data: %v", codecIDBytes[:n]) - } - conn.Close() - return - } - - conn.SetReadDeadline(time.Time{}) - codecID := binary.BigEndian.Uint32(codecIDBytes) - - switch codecID { - case protocol.CodecIDH264, protocol.CodecIDH265, protocol.CodecIDAV1: - log.Printf("Identified video stream with codec 0x%08x", codecID) - b.videoConn = conn - b.handleVideoStream(conn, codecID) - - case protocol.CodecIDOPUS, protocol.CodecIDAAC, protocol.CodecIDFLAC, protocol.CodecIDRAW: - log.Printf("Identified audio stream with codec 0x%08x", codecID) - b.audioConn = conn - b.handleAudioStream(conn, codecID) - - default: - if codecID == 0 { - log.Println("Stream explicitly disabled (codec ID = 0)") - } else if codecID == 1 { - log.Println("Stream configuration error (codec ID = 1)") - } else { - log.Printf("Unknown codec 0x%08x, treating as control stream", codecID) - b.handleControlStream(conn) - } - } -} - -// startMediaStreaming starts the main media streaming process -func (b *Bridge) startMediaStreaming(conn net.Conn) { - log.Println("Starting media streaming...") - - if conn == nil { - log.Println("Connection is nil!") - return - } - - // Read device metadata from the first connection - log.Println("Reading device metadata from first connection...") - - meta, err := device.ReadDeviceMeta(conn) - if err != nil { - log.Printf("Failed to read device metadata: %v", err) - meta = &device.DeviceMeta{ - DeviceName: "Unknown Device", - Width: 1080, - Height: 1920, - } - } - - log.Printf("Device: %s (%dx%d)", meta.DeviceName, meta.Width, meta.Height) - - // Start optimized streaming - go b.handleVideoStreamOptimized(conn) -} - -// handleVideoStreamOptimized processes the first video connection -func (b *Bridge) handleVideoStreamOptimized(conn net.Conn) { - if util.IsVerbose() { - log.Println("Processing optimized video stream") - } - - // Read codec ID from video stream - conn.SetReadDeadline(time.Now().Add(10 * time.Second)) - codecIDBytes := make([]byte, 4) - n, err := io.ReadFull(conn, codecIDBytes) - if err != nil { - log.Printf("Failed to read video codec ID: %v (read %d bytes)", err, n) - return - } - conn.SetReadDeadline(time.Time{}) - - codecID := binary.BigEndian.Uint32(codecIDBytes) - var codecName string - var isVideoCodec bool - - switch codecID { - case protocol.CodecIDH264: - codecName = "H264" - isVideoCodec = true - case protocol.CodecIDH265: - codecName = "H265" - isVideoCodec = true - case protocol.CodecIDAV1: - codecName = "AV1" - isVideoCodec = true - case 0x36340000: // Special case: "64" + nulls - treat as H264 - codecName = "H264 (fallback)" - isVideoCodec = true - codecID = protocol.CodecIDH264 // Use standard H264 ID - default: - codecName = fmt.Sprintf("UNKNOWN(0x%08x)", codecID) - isVideoCodec = false - } - log.Printf("Video codec: %s", codecName) - - // Verify it's a video codec - if isVideoCodec { - b.handleVideoStream(conn, codecID) - } else { - log.Printf("Expected video codec but got: %s", codecName) - conn.Close() - } -} - -// handleVideoStream processes video stream with codec -func (b *Bridge) handleVideoStream(conn net.Conn, codecID uint32) { - if util.IsVerbose() { - log.Printf("Starting video stream handler with codec ID: 0x%08x", codecID) - } - - // Read video dimensions - sizeData := make([]byte, 8) - if _, err := io.ReadFull(conn, sizeData); err != nil { - log.Printf("Failed to read video size: %v", err) - return - } - - width := binary.BigEndian.Uint32(sizeData[0:4]) - height := binary.BigEndian.Uint32(sizeData[4:8]) - b.VideoWidth = int(width) - b.VideoHeight = int(height) - log.Printf("Video stream dimensions: %dx%d", width, height) - - // Update control handler with actual screen dimensions - if b.controlHandler != nil { - b.controlHandler.UpdateScreenDimensions(int(width), int(height)) - } - - // Video track should already be created in NewBridge - if b.VideoTrack == nil { - log.Printf("Video track is nil! This should not happen.") - return - } - - // Request keyframe from scrcpy server - b.requestKeyFrame() - - // Start streaming video packets with ultra-low latency optimization - b.streamVideoOptimized(conn) -} - -// handleAudioStream processes audio stream -func (b *Bridge) handleAudioStream(conn net.Conn, codecID uint32) { - if util.IsVerbose() { - log.Printf("Starting audio stream handler with codec ID: 0x%08x", codecID) - } - - // Audio track should already be created in NewBridge - if b.AudioTrack == nil { - log.Printf("Audio track is nil! This should not happen.") - return - } - - // Start streaming audio packets - b.streamAudio(conn) -} - -// handleControlStream processes control stream -func (b *Bridge) handleControlStream(conn net.Conn) { - log.Println("Control stream handler started") - b.controlConn = conn - - // Update control handler with actual connection and screen dimensions - if b.controlHandler != nil { - b.controlHandler.UpdateConnection(conn) - // Update screen dimensions if available - if b.VideoWidth > 0 && b.VideoHeight > 0 { - b.controlHandler.UpdateScreenDimensions(b.VideoWidth, b.VideoHeight) - } - } else { - // Fallback: create new control handler if somehow not created - screenWidth := b.VideoWidth - screenHeight := b.VideoHeight - if screenWidth == 0 || screenHeight == 0 { - screenWidth = 1080 - screenHeight = 1920 - } - b.controlHandler = stream.NewControlHandler(conn, b.DataChannel, screenWidth, screenHeight) - b.controlHandler.HandleIncomingMessages() - } - - // Signal that control is ready (only once) - b.controlMutex.Lock() - select { - case <-b.controlReady: - // Already closed, do nothing - default: - close(b.controlReady) - } - b.controlMutex.Unlock() - - defer func() { - conn.Close() - b.controlConn = nil - b.controlHandler = nil - }() - - // Keep connection alive - don't try to read from it as it's write-only - // Just wait for context cancellation - <-b.Context.Done() - log.Println("Control connection closing due to context cancellation") -} - -// setupDataChannelHandlers sets up data channel event handlers -func (b *Bridge) setupDataChannelHandlers() { - // DataChannel will be set up when received via OnDataChannel - // This function is kept for future use if needed -} - -// DataChannel messages are now handled by ControlHandler -// This avoids duplication and ensures consistent message processing - -// handleKeyEvent delegates to control handler -func (b *Bridge) handleKeyEvent(message map[string]interface{}) { - // This is now handled by ControlHandler - // Keep for backward compatibility with WebSocket handlers -} - -// handleTouchEvent delegates to control handler -func (b *Bridge) handleTouchEvent(message map[string]interface{}) { - // This is now handled by ControlHandler - // Keep for backward compatibility with WebSocket handlers -} - -// requestKeyFrame requests a keyframe from the video encoder -func (b *Bridge) requestKeyFrame() { - if b.controlHandler != nil { - b.controlHandler.SendKeyFrameRequest() - } else { - log.Printf("Control handler not available for keyframe request") - } -} - -// Close closes the bridge and all its connections -func (b *Bridge) Close() error { - b.mu.Lock() - defer b.mu.Unlock() - - if b.closed { - return nil - } - b.closed = true - - // Cancel context - if b.cancel != nil { - b.cancel() - } - - // Close WebRTC connection - if b.WebRTCConn != nil { - b.WebRTCConn.Close() - } - - // Close stream connections - if b.videoConn != nil { - b.videoConn.Close() - } - if b.audioConn != nil { - b.audioConn.Close() - } - if b.controlConn != nil { - b.controlConn.Close() - } - - // Close scrcpy connection - if b.scrcpyConn != nil { - b.scrcpyConn.Close() - } - - log.Printf("Closed WebRTC bridge for device %s", b.DeviceSerial) - return nil -} - -// createVideoTrack creates a WebRTC video track -func (b *Bridge) createVideoTrack(codecID uint32) error { - if b.VideoTrack != nil { - return nil - } - - var mimeType string - var rtpCodecCap webrtc.RTPCodecCapability - - // RTCP feedback for keyframe requests - videoRTCPFeedback := []webrtc.RTCPFeedback{ - {Type: "ccm", Parameter: "fir"}, - {Type: "nack", Parameter: "pli"}, - {Type: "goog-remb", Parameter: ""}, - } - - switch codecID { - case protocol.CodecIDH264: - mimeType = webrtc.MimeTypeH264 - rtpCodecCap = webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, - ClockRate: 90000, - RTCPFeedback: videoRTCPFeedback, - } - case protocol.CodecIDH265: - mimeType = webrtc.MimeTypeH265 - rtpCodecCap = webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH265, - ClockRate: 90000, - RTCPFeedback: videoRTCPFeedback, - } - case protocol.CodecIDAV1: - mimeType = webrtc.MimeTypeAV1 - rtpCodecCap = webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeAV1, - ClockRate: 90000, - RTCPFeedback: videoRTCPFeedback, - } - default: - return fmt.Errorf("unsupported video codec: 0x%08x", codecID) - } - - // Force specific H.264 profile for better compatibility - if codecID == protocol.CodecIDH264 { - // Use baseline profile for faster decoding - rtpCodecCap.SDPFmtpLine = "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f" - } - - videoTrack, err := webrtc.NewTrackLocalStaticSample(rtpCodecCap, "video", "scrcpy-video") - if err != nil { - return fmt.Errorf("failed to create video track: %w", err) - } - - if _, err := b.WebRTCConn.AddTrack(videoTrack); err != nil { - return fmt.Errorf("failed to add video track: %w", err) - } - - b.VideoTrack = videoTrack - log.Printf("Created video track with codec %s", mimeType) - return nil -} - -// createAudioTrack creates a WebRTC audio track -func (b *Bridge) createAudioTrack() error { - if b.AudioTrack != nil { - return nil - } - - audioTrack, err := webrtc.NewTrackLocalStaticSample( - webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeOpus, - ClockRate: 48000, - Channels: 2, - }, - "audio", - "scrcpy-audio", - ) - if err != nil { - return fmt.Errorf("failed to create audio track: %w", err) - } - - if _, err := b.WebRTCConn.AddTrack(audioTrack); err != nil { - return fmt.Errorf("failed to add audio track: %w", err) - } - - b.AudioTrack = audioTrack - log.Println("Created audio track") - return nil -} - -// streamVideoOptimized streams video packets to WebRTC -func (b *Bridge) streamVideoOptimized(reader io.Reader) { - var lastVideoTimestamp int64 = 0 - packetCount := 0 - var h264Sps []byte - var h264Pps []byte - startCode := []byte{0x00, 0x00, 0x00, 0x01} - decoderReady := false - firstFrameSent := false - - for { - select { - case <-b.Context.Done(): - return - default: - packet, err := device.ReadVideoPacket(reader) - if err != nil { - select { - case <-b.Context.Done(): - return - default: - log.Printf("Failed to read video packet #%d: %v", packetCount, err) - return - } - } - - packetCount++ - - if b.VideoTrack == nil { - log.Println("Video track not initialized") - return - } - - // Calculate duration between frames - timestamp := int64(packet.PTS) - var duration time.Duration - if lastVideoTimestamp > 0 && timestamp > lastVideoTimestamp { - duration = time.Duration(timestamp-lastVideoTimestamp) * time.Microsecond - duration = min(duration, 33*time.Millisecond) // Cap at 30 FPS - } - lastVideoTimestamp = timestamp - - // Process config packets for SPS/PPS - if packet.IsConfig && len(packet.Data) >= 8 { - data := addStartCodeIfNeeded(packet.Data) - spsPpsInfo := bytes.Split(data, startCode) - - if len(spsPpsInfo) >= 3 { - if len(spsPpsInfo[1]) > 0 { - // ALWAYS use 4-byte start code for Chrome compatibility - h264Sps = append([]byte{0x00, 0x00, 0x00, 0x01}, spsPpsInfo[1]...) - sample := media.Sample{ - Data: h264Sps, - Duration: 0, // Zero duration for immediate processing - } - if err := b.VideoTrack.WriteSample(sample); err != nil { - log.Printf("Failed to write SPS: %v", err) - } - } - if len(spsPpsInfo[2]) > 0 { - // ALWAYS use 4-byte start code for Chrome compatibility - h264Pps = append([]byte{0x00, 0x00, 0x00, 0x01}, spsPpsInfo[2]...) - sample := media.Sample{ - Data: h264Pps, - Duration: 0, // Zero duration for immediate processing - } - if err := b.VideoTrack.WriteSample(sample); err != nil { - log.Printf("Failed to write PPS: %v", err) - } - } - } - - // Mark decoder ready after config packets - if len(h264Sps) > 0 && len(h264Pps) > 0 { - decoderReady = true - log.Printf("SPS/PPS ready for decoding") - } - continue - } - - // For keyframes, send SPS/PPS first (only if not already sent) - if packet.IsKeyFrame && len(h264Sps) > 0 && len(h264Pps) > 0 { - // Send SPS/PPS before keyframe for proper decoding - sample := media.Sample{Data: h264Sps, Duration: 0} - b.VideoTrack.WriteSample(sample) - - sample = media.Sample{Data: h264Pps, Duration: 0} - b.VideoTrack.WriteSample(sample) - - decoderReady = true - } - - // Decide whether to send frame - shouldSendFrame := false - - if packet.IsKeyFrame { - shouldSendFrame = true - if !firstFrameSent { - firstFrameSent = true - decoderReady = true - log.Printf("First keyframe received") - } - } else if decoderReady { - shouldSendFrame = true - } - - if shouldSendFrame { - processedData := addStartCodeIfNeeded(packet.Data) - sample := media.Sample{ - Data: processedData, - Duration: duration, - // No timestamp for minimal latency - } - if err := b.VideoTrack.WriteSample(sample); err != nil { - log.Printf("Failed to write video sample: %v", err) - return - } - - // Keyframes are now requested only when needed: - // - On connection establishment (handled in setupDataChannel) - // - On video size changes (handled by frontend) - // - On manual user requests - // Removed periodic keyframe requests to reduce log spam - } - } - } -} - -// streamAudio streams audio packets to WebRTC -func (b *Bridge) streamAudio(conn net.Conn) { - packetCount := 0 - - for { - select { - case <-b.Context.Done(): - return - default: - packet, err := device.ReadAudioPacket(conn) - if err != nil { - log.Printf("Failed to read audio packet #%d: %v", packetCount, err) - return - } - - packetCount++ - - if b.AudioTrack == nil { - log.Println("Audio track not initialized") - return - } - - // For Opus, use fixed 20ms frame duration - sample := media.Sample{ - Data: packet.Data, - Duration: 20 * time.Millisecond, - } - if err := b.AudioTrack.WriteSample(sample); err != nil { - log.Printf("Failed to write audio sample: %v", err) - return - } - } - } -} - -// Helper function to add H.264 start codes -func addStartCodeIfNeeded(data []byte) []byte { - if len(data) < 4 { - return data - } - - startCode3 := []byte{0x00, 0x00, 0x01} - startCode4 := []byte{0x00, 0x00, 0x00, 0x01} - - if len(data) >= 4 && bytes.Equal(data[:4], startCode4) { - return data - } - - if len(data) >= 3 && bytes.Equal(data[:3], startCode3) { - result := make([]byte, 0, len(data)+1) - result = append(result, startCode4...) - result = append(result, data[3:]...) - return result - } - - result := make([]byte, 0, len(data)+4) - result = append(result, startCode4...) - result = append(result, data...) - return result -} - -// Helper function for min -func min(a, b time.Duration) time.Duration { - if a < b { - return a - } - return b -} - -// SendControlMessage delegates to control handler -func (b *Bridge) SendControlMessage(msg *device.ControlMessage) error { - // This is now handled by ControlHandler - // Keep for backward compatibility - if b.controlHandler != nil { - // ControlHandler handles the actual sending - return nil - } - return fmt.Errorf("control handler not available") -} - -// HandleTouchEvent handles touch events from WebSocket -func (b *Bridge) HandleTouchEvent(message map[string]interface{}) { - if b.controlHandler != nil { - // Extract touch event parameters and delegate to control handler - action, _ := message["action"].(string) - x, _ := message["x"].(float64) - y, _ := message["y"].(float64) - pressure, _ := message["pressure"].(float64) - pointerId, _ := message["pointerId"].(float64) - - b.controlHandler.SendTouchEventToDevice(action, x, y, pressure, int(pointerId)) - } else { - log.Printf("Control handler not available for touch event") - } -} - -// HandleKeyEvent handles key events from WebSocket -func (b *Bridge) HandleKeyEvent(message map[string]interface{}) { - if b.controlHandler != nil { - // Extract key event parameters and delegate to control handler - action, _ := message["action"].(string) - keycode, _ := message["keycode"].(float64) - metaState, _ := message["metaState"].(float64) - repeat, _ := message["repeat"].(float64) - - b.controlHandler.SendKeyEventToDevice(action, int(keycode), int(metaState), int(repeat)) - } else { - log.Printf("Control handler not available for key event") - } -} - -// HandleScrollEvent handles scroll events from WebSocket -func (b *Bridge) HandleScrollEvent(message map[string]interface{}) { - if b.controlHandler != nil { - // Extract scroll event parameters and delegate to control handler - x, _ := message["x"].(float64) - y, _ := message["y"].(float64) - hScroll, _ := message["hScroll"].(float64) - vScroll, _ := message["vScroll"].(float64) - - b.controlHandler.SendScrollEventToDevice(x, y, hScroll, vScroll) - } else { - log.Printf("Control handler not available for scroll event") - } -} - -// handleScrollEvent delegates to control handler -func (b *Bridge) handleScrollEvent(message map[string]interface{}) { - // This is now handled by ControlHandler - // Keep for backward compatibility with WebSocket handlers -} diff --git a/packages/cli/internal/device_connect/webrtc/debug.go b/packages/cli/internal/device_connect/webrtc/debug.go deleted file mode 100644 index 203f1e18..00000000 --- a/packages/cli/internal/device_connect/webrtc/debug.go +++ /dev/null @@ -1,25 +0,0 @@ -package webrtc - -import ( - "encoding/hex" - "io" - "log" -) - -// DebugReader wraps an io.Reader to log what's being read -type DebugReader struct { - reader io.Reader - name string -} - -func NewDebugReader(reader io.Reader, name string) *DebugReader { - return &DebugReader{reader: reader, name: name} -} - -func (d *DebugReader) Read(p []byte) (n int, err error) { - n, err = d.reader.Read(p) - if n > 0 { - log.Printf("[%s] Read %d bytes: %s", d.name, n, hex.EncodeToString(p[:n])) - } - return n, err -} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/webrtc/manager.go b/packages/cli/internal/device_connect/webrtc/manager.go deleted file mode 100644 index ccc1bb67..00000000 --- a/packages/cli/internal/device_connect/webrtc/manager.go +++ /dev/null @@ -1,86 +0,0 @@ -package webrtc - -import ( - "log" - "sync" -) - -// Manager manages WebRTC bridges for multiple devices -type Manager struct { - bridges map[string]*Bridge - mu sync.RWMutex - adbPath string -} - -// NewManager creates a new WebRTC manager -func NewManager(adbPath string) *Manager { - return &Manager{ - bridges: make(map[string]*Bridge), - adbPath: adbPath, - } -} - -// CreateBridge creates a new WebRTC bridge for a device -func (m *Manager) CreateBridge(deviceSerial string) (*Bridge, error) { - m.mu.Lock() - defer m.mu.Unlock() - - // Remove existing bridge if any - if existing, exists := m.bridges[deviceSerial]; exists { - existing.Close() - delete(m.bridges, deviceSerial) - } - - // Create new bridge - bridge, err := NewBridge(deviceSerial, m.adbPath) - if err != nil { - return nil, err - } - - // Start the bridge - if err := bridge.Start(); err != nil { - bridge.Close() - return nil, err - } - - m.bridges[deviceSerial] = bridge - log.Printf("Created WebRTC bridge for device %s", deviceSerial) - - return bridge, nil -} - -// GetBridge returns an existing bridge for a device -func (m *Manager) GetBridge(deviceSerial string) (*Bridge, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - - bridge, exists := m.bridges[deviceSerial] - return bridge, exists -} - -// RemoveBridge removes and closes a bridge for a device -func (m *Manager) RemoveBridge(deviceSerial string) { - m.mu.Lock() - defer m.mu.Unlock() - - if bridge, exists := m.bridges[deviceSerial]; exists { - bridge.Close() - delete(m.bridges, deviceSerial) - log.Printf("Removed WebRTC bridge for device %s", deviceSerial) - } -} - -// Close closes all bridges -func (m *Manager) Close() error { - m.mu.Lock() - defer m.mu.Unlock() - - for serial, bridge := range m.bridges { - if err := bridge.Close(); err != nil { - log.Printf("Error closing bridge for device %s: %v", serial, err) - } - } - - m.bridges = make(map[string]*Bridge) - return nil -} \ No newline at end of file diff --git a/packages/cli/internal/server/device_connect.go b/packages/cli/internal/server/device_connect.go deleted file mode 100644 index 3268a47f..00000000 --- a/packages/cli/internal/server/device_connect.go +++ /dev/null @@ -1,497 +0,0 @@ -package server - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os/exec" - "strings" - - "github.com/gorilla/websocket" - "github.com/pion/webrtc/v4" -) - -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true // Allow all origins for now - }, -} - -// Device Connect API handlers - -func (s *GBoxServer) handleDevices(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - devices, err := s.getADBDevices() - 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 - } - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "devices": devices, - "onDemandEnabled": true, - }) -} - -func (s *GBoxServer) handleDeviceAction(w http.ResponseWriter, r *http.Request) { - // Parse URL path: /api/devices/{id}/{action} - path := strings.TrimPrefix(r.URL.Path, "/api/devices/") - parts := strings.Split(path, "/") - - if len(parts) != 2 { - http.Error(w, "Invalid path", http.StatusBadRequest) - return - } - - deviceID := parts[0] - action := parts[1] - - switch action { - case "connect": - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - s.handleDeviceConnect(w, r, deviceID) - case "disconnect": - if r.Method != http.MethodDelete { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - s.handleDeviceDisconnect(w, r, deviceID) - default: - http.Error(w, "Unknown action", http.StatusNotFound) - } -} - -func (s *GBoxServer) handleDeviceConnect(w http.ResponseWriter, r *http.Request, deviceID string) { - // Register the device with WebRTC manager - bridge, err := s.webrtcManager.CreateBridge(deviceID) - if err != nil { - log.Printf("Failed to create bridge for device %s: %v", deviceID, err) - respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - return - } - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "deviceId": deviceID, - "bridgeId": bridge.DeviceSerial, - "message": "Device connected successfully", - }) -} - -func (s *GBoxServer) handleDeviceDisconnect(w http.ResponseWriter, r *http.Request, deviceID string) { - // Unregister the device from WebRTC manager - s.webrtcManager.RemoveBridge(deviceID) - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "deviceId": deviceID, - "message": "Device disconnected successfully", - }) -} - -func (s *GBoxServer) handleRegisterDevice(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - DeviceID string `json:"deviceId"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondJSON(w, http.StatusBadRequest, map[string]interface{}{ - "success": false, - "error": "Invalid request body", - }) - return - } - - bridge, err := s.webrtcManager.CreateBridge(req.DeviceID) - if err != nil { - log.Printf("Failed to create bridge for device %s: %v", req.DeviceID, err) - respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - return - } - - log.Printf("Successfully registered device %s", req.DeviceID) - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "device_id": bridge.DeviceSerial, - "message": "Device registered successfully", - }) -} - -func (s *GBoxServer) handleUnregisterDevice(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - DeviceID string `json:"deviceId"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondJSON(w, http.StatusBadRequest, map[string]interface{}{ - "success": false, - "error": "Invalid request body", - }) - return - } - - s.webrtcManager.RemoveBridge(req.DeviceID) - - log.Printf("Successfully unregistered device %s", req.DeviceID) - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "message": "Device unregistered successfully", - }) -} - -func (s *GBoxServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { - // Proxy WebSocket to device_connect server - // For now, use the existing webrtc manager logic - // TODO: Implement proper proxy to device_connect server - - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Printf("Failed to upgrade WebSocket: %v", err) - return - } - defer conn.Close() - - log.Println("WebSocket connection established (main server)") - - for { - var msg map[string]interface{} - if err := conn.ReadJSON(&msg); err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - log.Printf("WebSocket read error: %v", err) - } - break - } - - msgType, ok := msg["type"].(string) - if !ok { - continue - } - - switch msgType { - case "connect": - s.handleWebSocketConnect(conn, msg) - case "offer": - s.handleWebSocketOffer(conn, msg) - case "ice-candidate": - s.handleWebSocketICECandidate(conn, msg) - case "disconnect": - s.handleWebSocketDisconnect(conn, msg) - } - } -} - -func (s *GBoxServer) handleWebSocketConnect(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": "Device serial required", - }) - return - } - - bridge, exists := s.webrtcManager.GetBridge(deviceSerial) - if !exists { - var err error - bridge, err = s.webrtcManager.CreateBridge(deviceSerial) - if err != nil { - log.Printf("Failed to create bridge: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": err.Error(), - }) - return - } - } - - bridge.WSConnection = conn - - conn.WriteJSON(map[string]interface{}{ - "type": "connected", - "deviceSerial": deviceSerial, - }) -} - -func (s *GBoxServer) handleWebSocketOffer(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - return - } - - offerData, ok := msg["offer"].(map[string]interface{}) - if !ok { - return - } - - sdp, ok := offerData["sdp"].(string) - if !ok { - return - } - - // Get or create bridge for the device - bridge, exists := s.webrtcManager.GetBridge(deviceSerial) - if !exists { - log.Printf("Bridge not found for device %s, creating new bridge", deviceSerial) - var err error - bridge, err = s.webrtcManager.CreateBridge(deviceSerial) - if err != nil { - log.Printf("Failed to create bridge: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": fmt.Sprintf("Failed to connect to device: %v", err), - }) - return - } - } - - // Check signaling state - only recreate if truly necessary - signalingState := bridge.WebRTCConn.SignalingState() - connState := bridge.WebRTCConn.ConnectionState() - - log.Printf("Bridge state for device %s: signaling=%s, connection=%s", deviceSerial, signalingState, connState) - - // Only recreate bridge if connection is truly closed or failed - if connState == webrtc.PeerConnectionStateClosed || connState == webrtc.PeerConnectionStateFailed { - log.Printf("WebRTC connection is %s for device %s, recreating bridge", connState, deviceSerial) - s.webrtcManager.RemoveBridge(deviceSerial) - - // Create new bridge - var err error - bridge, err = s.webrtcManager.CreateBridge(deviceSerial) - if err != nil { - log.Printf("Failed to recreate bridge: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": fmt.Sprintf("Failed to reconnect to device: %v", err), - }) - return - } - } else if signalingState == webrtc.SignalingStateClosed { - // Only recreate if signaling is closed but connection is still active - log.Printf("Signaling state is closed for device %s, recreating bridge", deviceSerial) - s.webrtcManager.RemoveBridge(deviceSerial) - - var err error - bridge, err = s.webrtcManager.CreateBridge(deviceSerial) - if err != nil { - log.Printf("Failed to recreate bridge: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": fmt.Sprintf("Failed to reset connection: %v", err), - }) - return - } - } - - offer := webrtc.SessionDescription{ - Type: webrtc.SDPTypeOffer, - SDP: sdp, - } - - if err := bridge.WebRTCConn.SetRemoteDescription(offer); err != nil { - log.Printf("Failed to set remote description: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": err.Error(), - }) - return - } - - answer, err := bridge.WebRTCConn.CreateAnswer(nil) - if err != nil { - log.Printf("Failed to create answer: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": err.Error(), - }) - return - } - - if err := bridge.WebRTCConn.SetLocalDescription(answer); err != nil { - log.Printf("Failed to set local description: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": err.Error(), - }) - return - } - - conn.WriteJSON(map[string]interface{}{ - "type": "answer", - "answer": map[string]interface{}{ - "type": "answer", - "sdp": answer.SDP, - }, - }) - - bridge.WebRTCConn.OnICECandidate(func(candidate *webrtc.ICECandidate) { - if candidate == nil { - return - } - - candidateJSON := candidate.ToJSON() - conn.WriteJSON(map[string]interface{}{ - "type": "ice-candidate", - "candidate": map[string]interface{}{ - "candidate": candidateJSON.Candidate, - "sdpMLineIndex": candidateJSON.SDPMLineIndex, - "sdpMid": candidateJSON.SDPMid, - }, - }) - }) - - // Device info is not needed by frontend, video dimensions will be available through video track -} - -func (s *GBoxServer) handleWebSocketICECandidate(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - return - } - - candidateData, ok := msg["candidate"].(map[string]interface{}) - if !ok { - return - } - - bridge, exists := s.webrtcManager.GetBridge(deviceSerial) - if !exists { - return - } - - candidate := webrtc.ICECandidateInit{ - Candidate: candidateData["candidate"].(string), - } - - if sdpMLineIndex, ok := candidateData["sdpMLineIndex"].(float64); ok { - index := uint16(sdpMLineIndex) - candidate.SDPMLineIndex = &index - } - - if sdpMid, ok := candidateData["sdpMid"].(string); ok { - candidate.SDPMid = &sdpMid - } - - if err := bridge.WebRTCConn.AddICECandidate(candidate); err != nil { - log.Printf("Failed to add ICE candidate: %v", err) - } -} - -func (s *GBoxServer) handleWebSocketDisconnect(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - return - } - - s.webrtcManager.RemoveBridge(deviceSerial) - - conn.WriteJSON(map[string]interface{}{ - "type": "disconnected", - }) -} - -func (s *GBoxServer) getADBDevices() ([]map[string]interface{}, error) { - adbPath, err := exec.LookPath("adb") - if err != nil { - return nil, fmt.Errorf("adb not found in PATH") - } - - cmd := exec.Command(adbPath, "devices", "-l") - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to run adb devices: %w", err) - } - - lines := strings.Split(string(output), "\n") - devices := []map[string]interface{}{} - - for _, line := range lines[1:] { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - parts := strings.Fields(line) - if len(parts) < 2 { - continue - } - - serial := parts[0] - state := parts[1] - - if state != "device" { - continue - } - - device := map[string]interface{}{ - "id": serial, - "udid": serial, - "state": state, - "ro.serialno": serial, - "connectionType": "usb", - "isRegistrable": false, - } - - if strings.Contains(line, "model:") { - if idx := strings.Index(line, "model:"); idx != -1 { - modelPart := line[idx+6:] - if spaceIdx := strings.Index(modelPart, " "); spaceIdx != -1 { - device["ro.product.model"] = modelPart[:spaceIdx] - } else { - device["ro.product.model"] = modelPart - } - } - } - - if strings.Contains(line, "device:") { - if idx := strings.Index(line, "device:"); idx != -1 { - devicePart := line[idx+7:] - if spaceIdx := strings.Index(devicePart, " "); spaceIdx != -1 { - device["ro.product.manufacturer"] = devicePart[:spaceIdx] - } - } - } - - if strings.Contains(serial, ":") { - device["connectionType"] = "ip" - } - - if _, exists := s.webrtcManager.GetBridge(serial); exists { - device["isRegistrable"] = true - } - - devices = append(devices, device) - } - - return devices, nil -} diff --git a/packages/cli/internal/server/adb_expose.go b/packages/cli/internal/server/handlers/adb_expose.go similarity index 56% rename from packages/cli/internal/server/adb_expose.go rename to packages/cli/internal/server/handlers/adb_expose.go index 77504fca..5ec510b2 100644 --- a/packages/cli/internal/server/adb_expose.go +++ b/packages/cli/internal/server/handlers/adb_expose.go @@ -1,4 +1,4 @@ -package server +package handlers import ( "encoding/json" @@ -13,10 +13,10 @@ import ( "github.com/babelcloud/gbox/packages/cli/internal/profile" ) -// ADBExposeService manages ADB port expose for remote boxes -type ADBExposeService struct { - mu sync.RWMutex - running bool +// ADBExposeHandlers contains handlers for ADB expose functionality +type ADBExposeHandlers struct { + portManager *PortManager + connectionPool *ConnectionPool } // BoxPortForward represents an active port forward for a remote box @@ -29,90 +29,64 @@ type BoxPortForward struct { Error string `json:"error,omitempty"` } -// NewADBExposeService creates a new ADB expose service -func NewADBExposeService() *ADBExposeService { - return &ADBExposeService{} +// PortForward manages a single port forwarding session +type PortForward struct { + BoxID string `json:"box_id"` + LocalPorts []int `json:"local_ports"` + RemotePorts []int `json:"remote_ports"` + StartedAt time.Time `json:"started_at"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + client *adb_expose.MultiplexClient + listeners []net.Listener + mu sync.RWMutex } -// Start starts the ADB expose service -func (s *ADBExposeService) Start() error { - s.mu.Lock() - defer s.mu.Unlock() +// Stop stops the port forward +func (pf *PortForward) Stop() { + pf.mu.Lock() + defer pf.mu.Unlock() - s.running = true - log.Println("ADB Expose service started") - return nil + pf.Status = "stopped" + for _, listener := range pf.listeners { + listener.Close() + } } -// Stop stops the ADB expose service -func (s *ADBExposeService) Stop() error { - s.mu.Lock() - defer s.mu.Unlock() - - s.running = false - log.Println("ADB Expose service stopped") - return nil +// PortManager manages multiple port forwards +type PortManager struct { + forwards map[string]*PortForward + mu sync.RWMutex } -// Close closes the service -func (s *ADBExposeService) Close() error { - return s.Stop() +// ConnectionPool manages WebSocket connections +type ConnectionPool struct { + connections map[string]*adb_expose.MultiplexClient + mu sync.RWMutex } -// IsRunning returns whether the service is running -func (s *ADBExposeService) IsRunning() bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.running +// StartRequest represents a start port forward request +type StartRequest struct { + BoxID string `json:"box_id"` + LocalPorts []int `json:"local_ports"` + RemotePorts []int `json:"remote_ports"` + Config adb_expose.Config `json:"config"` } -// StartBoxPortForward starts ADB port expose for a remote box -func (s *ADBExposeService) StartBoxPortForward(boxID string, localPorts, remotePorts []int) error { - s.mu.RLock() - defer s.mu.RUnlock() - - if !s.running { - return fmt.Errorf("service not running") +// NewADBExposeHandlers creates a new ADB expose handlers instance +func NewADBExposeHandlers() *ADBExposeHandlers { + return &ADBExposeHandlers{ + portManager: &PortManager{ + forwards: make(map[string]*PortForward), + }, + connectionPool: &ConnectionPool{ + connections: make(map[string]*adb_expose.MultiplexClient), + }, } - - // ADB expose is now handled by the main server's HTTP handlers - // This method is kept for compatibility but the actual work is done - // by the HTTP handlers that call the new ADB expose implementation - - log.Printf("ADB port expose request for box %s: local ports %v -> remote ports %v", boxID, localPorts, remotePorts) - return nil } -// StopBoxPortForward stops ADB port expose for a remote box -func (s *ADBExposeService) StopBoxPortForward(boxID string) error { - s.mu.RLock() - defer s.mu.RUnlock() - - if !s.running { - return fmt.Errorf("service not running") - } - - log.Printf("ADB port expose stop request for box %s", boxID) - return nil -} - -// ListBoxPortForwards returns all active box port forwards -func (s *ADBExposeService) ListBoxPortForwards() ([]*BoxPortForward, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if !s.running { - return nil, fmt.Errorf("service not running") - } - - // ADB expose is now handled by the main server's HTTP handlers - // Return empty list for compatibility - return make([]*BoxPortForward, 0), nil -} - -// HTTP Handlers for ADB Expose - -func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request) { +// HandleADBExposeStart handles ADB expose start requests +func (h *ADBExposeHandlers) HandleADBExposeStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -125,7 +99,7 @@ func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondJSON(w, http.StatusBadRequest, map[string]string{ + RespondJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid request body", }) return @@ -133,21 +107,21 @@ func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request // Validate request if req.BoxID == "" { - respondJSON(w, http.StatusBadRequest, map[string]string{ + RespondJSON(w, http.StatusBadRequest, map[string]string{ "error": "box_id is required", }) return } if len(req.LocalPorts) == 0 || len(req.RemotePorts) == 0 { - respondJSON(w, http.StatusBadRequest, map[string]string{ + RespondJSON(w, http.StatusBadRequest, map[string]string{ "error": "local_ports and remote_ports are required", }) return } if len(req.LocalPorts) != len(req.RemotePorts) { - respondJSON(w, http.StatusBadRequest, map[string]string{ + RespondJSON(w, http.StatusBadRequest, map[string]string{ "error": "local_ports and remote_ports must have the same length", }) return @@ -164,7 +138,7 @@ func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request // We need to get the configuration first pm := profile.NewProfileManager() if err := pm.Load(); err != nil { - respondJSON(w, http.StatusInternalServerError, map[string]string{ + RespondJSON(w, http.StatusInternalServerError, map[string]string{ "error": "Failed to load profile manager: " + err.Error(), }) return @@ -176,7 +150,7 @@ func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request // Try to use the first available profile profiles := pm.GetProfiles() if len(profiles) == 0 { - respondJSON(w, http.StatusInternalServerError, map[string]string{ + RespondJSON(w, http.StatusInternalServerError, map[string]string{ "error": "No profiles available. Please run 'gbox profile add' to add a profile first", }) return @@ -189,7 +163,7 @@ func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request } if err := pm.Use(firstProfileID); err != nil { - respondJSON(w, http.StatusInternalServerError, map[string]string{ + RespondJSON(w, http.StatusInternalServerError, map[string]string{ "error": "Failed to set profile: " + err.Error(), }) return @@ -197,7 +171,7 @@ func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request apiKey, err = pm.GetCurrentAPIKey() if err != nil { - respondJSON(w, http.StatusInternalServerError, map[string]string{ + RespondJSON(w, http.StatusInternalServerError, map[string]string{ "error": "Failed to get API key: " + err.Error(), }) return @@ -206,7 +180,7 @@ func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request gboxURL := profile.Default.GetEffectiveBaseURL() if gboxURL == "" { - respondJSON(w, http.StatusInternalServerError, map[string]string{ + RespondJSON(w, http.StatusInternalServerError, map[string]string{ "error": "GBOX base URL not configured", }) return @@ -223,24 +197,25 @@ func (s *GBoxServer) handleADBExposeStart(w http.ResponseWriter, r *http.Request // Start port forwarding directly log.Printf("Starting ADB port forward for box %s", req.BoxID) - forward, err := s.startPortForward(adbReq) + forward, err := h.startPortForward(adbReq) if err != nil { log.Printf("Failed to start ADB port forward: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{ + RespondJSON(w, http.StatusInternalServerError, map[string]string{ "error": "Failed to start ADB port expose: " + err.Error(), }) return } log.Printf("ADB port forward started successfully for box %s", req.BoxID) - respondJSON(w, http.StatusOK, map[string]interface{}{ + RespondJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "message": fmt.Sprintf("ADB port exposed for box %s: %v -> %v", req.BoxID, req.LocalPorts, req.RemotePorts), "data": forward, }) } -func (s *GBoxServer) handleADBExposeStop(w http.ResponseWriter, r *http.Request) { +// HandleADBExposeStop handles ADB expose stop requests +func (h *ADBExposeHandlers) HandleADBExposeStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -252,7 +227,7 @@ func (s *GBoxServer) handleADBExposeStop(w http.ResponseWriter, r *http.Request) if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // If no body, stop all forwards - respondJSON(w, http.StatusBadRequest, map[string]string{ + RespondJSON(w, http.StatusBadRequest, map[string]string{ "error": "box_id is required", }) return @@ -260,7 +235,7 @@ func (s *GBoxServer) handleADBExposeStop(w http.ResponseWriter, r *http.Request) // Validate request if req.BoxID == "" { - respondJSON(w, http.StatusBadRequest, map[string]string{ + RespondJSON(w, http.StatusBadRequest, map[string]string{ "error": "box_id is required", }) return @@ -268,55 +243,49 @@ func (s *GBoxServer) handleADBExposeStop(w http.ResponseWriter, r *http.Request) // Stop port forwarding for the specific box log.Printf("Stopping ADB port forward for box %s", req.BoxID) - if err := s.stopPortForward(req.BoxID); err != nil { + if err := h.stopPortForward(req.BoxID); err != nil { log.Printf("Failed to stop ADB port forward: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{ + RespondJSON(w, http.StatusInternalServerError, map[string]string{ "error": "Failed to stop ADB port expose: " + err.Error(), }) return } log.Printf("ADB port forward stopped for box %s", req.BoxID) - respondJSON(w, http.StatusOK, map[string]interface{}{ + RespondJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "message": fmt.Sprintf("ADB port expose stopped for box %s", req.BoxID), }) } -func (s *GBoxServer) handleADBExposeStatus(w http.ResponseWriter, r *http.Request) { +// HandleADBExposeStatus handles ADB expose status requests +func (h *ADBExposeHandlers) HandleADBExposeStatus(w http.ResponseWriter, r *http.Request) { status := map[string]interface{}{ - "running": s.adbExpose.IsRunning(), + "running": true, // Always running as part of main server } - // Get box port forwards if service is running - if s.adbExpose.IsRunning() { - forwards, err := s.adbExpose.ListBoxPortForwards() - if err != nil { - status["error"] = err.Error() - } else { - status["forwards"] = forwards - } - } else { - status["forwards"] = []*BoxPortForward{} - } + // Get box port forwards + forwards := h.listPortForwards() + status["forwards"] = forwards - respondJSON(w, http.StatusOK, status) + RespondJSON(w, http.StatusOK, status) } -func (s *GBoxServer) handleADBExposeList(w http.ResponseWriter, r *http.Request) { +// HandleADBExposeList handles ADB expose list requests +func (h *ADBExposeHandlers) HandleADBExposeList(w http.ResponseWriter, r *http.Request) { // Get port forwards directly from port manager - forwards := s.listPortForwards() + forwards := h.listPortForwards() - respondJSON(w, http.StatusOK, map[string]interface{}{ + RespondJSON(w, http.StatusOK, map[string]interface{}{ "forwards": forwards, "count": len(forwards), }) } // startPortForward starts port forwarding for a box -func (s *GBoxServer) startPortForward(req StartRequest) (*PortForward, error) { +func (h *ADBExposeHandlers) startPortForward(req StartRequest) (*PortForward, error) { // Get or create WebSocket connection - client, err := s.getOrCreateConnection(req.BoxID, req.Config) + client, err := h.getOrCreateConnection(req.BoxID, req.Config) if err != nil { return nil, fmt.Errorf("failed to get connection: %v", err) } @@ -332,14 +301,14 @@ func (s *GBoxServer) startPortForward(req StartRequest) (*PortForward, error) { } // Store the port forward in the manager - s.portManager.mu.Lock() - s.portManager.forwards[req.BoxID] = forward - s.portManager.mu.Unlock() + h.portManager.mu.Lock() + h.portManager.forwards[req.BoxID] = forward + h.portManager.mu.Unlock() // Start local listeners for each port for i, localPort := range req.LocalPorts { remotePort := req.RemotePorts[i] - go s.startLocalListener(forward, localPort, remotePort) + go h.startLocalListener(forward, localPort, remotePort) } forward.Status = "running" @@ -347,11 +316,11 @@ func (s *GBoxServer) startPortForward(req StartRequest) (*PortForward, error) { } // stopPortForward stops port forwarding for a box -func (s *GBoxServer) stopPortForward(boxID string) error { - s.portManager.mu.Lock() - defer s.portManager.mu.Unlock() +func (h *ADBExposeHandlers) stopPortForward(boxID string) error { + h.portManager.mu.Lock() + defer h.portManager.mu.Unlock() - forward, exists := s.portManager.forwards[boxID] + forward, exists := h.portManager.forwards[boxID] if !exists { return fmt.Errorf("port forward not found for box %s", boxID) } @@ -365,18 +334,18 @@ func (s *GBoxServer) stopPortForward(boxID string) error { } // Remove from manager - delete(s.portManager.forwards, boxID) + delete(h.portManager.forwards, boxID) return nil } // listPortForwards returns all active port forwards -func (s *GBoxServer) listPortForwards() []*BoxPortForward { - s.portManager.mu.RLock() - defer s.portManager.mu.RUnlock() +func (h *ADBExposeHandlers) listPortForwards() []*BoxPortForward { + h.portManager.mu.RLock() + defer h.portManager.mu.RUnlock() - boxForwards := make([]*BoxPortForward, 0, len(s.portManager.forwards)) - for _, forward := range s.portManager.forwards { + boxForwards := make([]*BoxPortForward, 0, len(h.portManager.forwards)) + for _, forward := range h.portManager.forwards { boxForward := &BoxPortForward{ BoxID: forward.BoxID, LocalPorts: forward.LocalPorts, @@ -392,12 +361,12 @@ func (s *GBoxServer) listPortForwards() []*BoxPortForward { } // getOrCreateConnection gets or creates a WebSocket connection for a box -func (s *GBoxServer) getOrCreateConnection(boxID string, config adb_expose.Config) (*adb_expose.MultiplexClient, error) { - s.connectionPool.mu.Lock() - defer s.connectionPool.mu.Unlock() +func (h *ADBExposeHandlers) getOrCreateConnection(boxID string, config adb_expose.Config) (*adb_expose.MultiplexClient, error) { + h.connectionPool.mu.Lock() + defer h.connectionPool.mu.Unlock() // Check if connection already exists - if client, exists := s.connectionPool.connections[boxID]; exists { + if client, exists := h.connectionPool.connections[boxID]; exists { return client, nil } @@ -413,17 +382,17 @@ func (s *GBoxServer) getOrCreateConnection(boxID string, config adb_expose.Confi log.Printf("WebSocket connection closed for box %s: %v", boxID, err) } // Remove from connection pool on error - s.connectionPool.mu.Lock() - delete(s.connectionPool.connections, boxID) - s.connectionPool.mu.Unlock() + h.connectionPool.mu.Lock() + delete(h.connectionPool.connections, boxID) + h.connectionPool.mu.Unlock() }() - s.connectionPool.connections[boxID] = client + h.connectionPool.connections[boxID] = client return client, nil } // startLocalListener starts a local listener for port forwarding -func (s *GBoxServer) startLocalListener(forward *PortForward, localPort, remotePort int) { +func (h *ADBExposeHandlers) startLocalListener(forward *PortForward, localPort, remotePort int) { listener, err := net.Listen("tcp", fmt.Sprintf(":%d", localPort)) if err != nil { forward.mu.Lock() @@ -434,6 +403,11 @@ func (s *GBoxServer) startLocalListener(forward *PortForward, localPort, remoteP } defer listener.Close() + // Store listener for cleanup + forward.mu.Lock() + forward.listeners = append(forward.listeners, listener) + forward.mu.Unlock() + log.Printf("Listening on port %d for box %s", localPort, forward.BoxID) for { @@ -449,4 +423,4 @@ func (s *GBoxServer) startLocalListener(forward *PortForward, localPort, remoteP // Handle connection in goroutine go adb_expose.HandleLocalConnWithClient(conn, forward.client, remotePort) } -} +} \ No newline at end of file diff --git a/packages/cli/internal/server/handlers/api.go b/packages/cli/internal/server/handlers/api.go new file mode 100644 index 00000000..a980d98b --- /dev/null +++ b/packages/cli/internal/server/handlers/api.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "net/http" + "os" + "time" +) + +// APIHandlers contains handlers for all /api/* routes +type APIHandlers struct { + serverService ServerService + adbExposeHandlers *ADBExposeHandlers + deviceHandlers *DeviceHandlers +} + +// NewAPIHandlers creates a new API handlers instance +func NewAPIHandlers(serverSvc ServerService) *APIHandlers { + return &APIHandlers{ + serverService: serverSvc, + adbExposeHandlers: NewADBExposeHandlers(), + deviceHandlers: NewDeviceHandlers(serverSvc), + } +} + +// Health and status endpoints +func (h *APIHandlers) HandleHealth(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"healthy","service":"gbox-server"}`)) +} + +func (h *APIHandlers) HandleStatus(w http.ResponseWriter, req *http.Request) { + if h.serverService == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"running","service":"gbox-server"}`)) + return + } + + uptime := h.serverService.GetUptime() + + status := map[string]interface{}{ + "running": h.serverService.IsRunning(), + "port": h.serverService.GetPort(), + "uptime": uptime.String(), + "services": map[string]interface{}{ + "device_connect": true, + "adb_expose": h.serverService.IsADBExposeRunning(), + }, + "version": h.serverService.GetVersion(), + "build_id": h.serverService.GetBuildID(), + } + + RespondJSON(w, http.StatusOK, status) +} + +// Device management endpoints - delegate to dedicated handlers +func (h *APIHandlers) HandleDeviceList(w http.ResponseWriter, req *http.Request) { + h.deviceHandlers.HandleDeviceList(w, req) +} + +func (h *APIHandlers) HandleDeviceAction(w http.ResponseWriter, req *http.Request) { + h.deviceHandlers.HandleDeviceAction(w, req) +} + +func (h *APIHandlers) HandleDeviceRegister(w http.ResponseWriter, req *http.Request) { + h.deviceHandlers.HandleDeviceRegister(w, req) +} + +func (h *APIHandlers) HandleDeviceUnregister(w http.ResponseWriter, req *http.Request) { + h.deviceHandlers.HandleDeviceUnregister(w, req) +} + +// ADB Expose endpoints - delegate to dedicated handlers +func (h *APIHandlers) HandleADBExposeStart(w http.ResponseWriter, req *http.Request) { + h.adbExposeHandlers.HandleADBExposeStart(w, req) +} + +func (h *APIHandlers) HandleADBExposeStop(w http.ResponseWriter, req *http.Request) { + h.adbExposeHandlers.HandleADBExposeStop(w, req) +} + +func (h *APIHandlers) HandleADBExposeStatus(w http.ResponseWriter, req *http.Request) { + h.adbExposeHandlers.HandleADBExposeStatus(w, req) +} + +func (h *APIHandlers) HandleADBExposeList(w http.ResponseWriter, req *http.Request) { + h.adbExposeHandlers.HandleADBExposeList(w, req) +} + +// Server management endpoints +func (h *APIHandlers) HandleServerShutdown(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if h.serverService == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + w.Write([]byte(`{"message":"Server shutdown not yet implemented in new architecture"}`)) + return + } + + RespondJSON(w, http.StatusOK, map[string]string{ + "message": "Server shutting down", + }) + + // Shutdown after response + go func() { + time.Sleep(100 * time.Millisecond) + h.serverService.Stop() + os.Exit(0) + }() +} + +func (h *APIHandlers) HandleServerInfo(w http.ResponseWriter, req *http.Request) { + if h.serverService == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"name":"gbox-server","version":"dev","message":"Server info not yet fully implemented in new architecture"}`)) + return + } + + uptime := h.serverService.GetUptime() + + info := map[string]interface{}{ + "version": h.serverService.GetVersion(), + "build_id": h.serverService.GetBuildID(), + "port": h.serverService.GetPort(), + "uptime": uptime.String(), + "services": []string{ + "device-connect", + "adb-expose", + }, + } + + // Set CORS headers for debugging + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + RespondJSON(w, http.StatusOK, info) +} + + diff --git a/packages/cli/internal/server/handlers/assets.go b/packages/cli/internal/server/handlers/assets.go new file mode 100644 index 00000000..b5ff385e --- /dev/null +++ b/packages/cli/internal/server/handlers/assets.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "io" + "io/fs" + "net/http" + "strings" + "time" +) + +// AssetsHandlers contains handlers for all /assets/* routes +type AssetsHandlers struct { + staticFS fs.FS +} + +// NewAssetsHandlers creates a new assets handlers instance +func NewAssetsHandlers(staticFS fs.FS) *AssetsHandlers { + return &AssetsHandlers{ + staticFS: staticFS, + } +} + +// HandleAssets serves static assets +func (h *AssetsHandlers) HandleAssets(w http.ResponseWriter, req *http.Request) { + // Extract the asset path + assetPath := strings.TrimPrefix(req.URL.Path, "/assets/") + + // Try to serve from embedded live-view assets + if h.staticFS != nil { + // First try the assets directory + file, err := h.staticFS.Open("static/live-view/assets/" + assetPath) + if err == nil { + defer file.Close() + // Set appropriate content type + if strings.HasSuffix(assetPath, ".js") { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + } else if strings.HasSuffix(assetPath, ".css") { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + } + http.ServeContent(w, req, assetPath, time.Time{}, file.(io.ReadSeeker)) + return + } + + } + + http.NotFound(w, req) +} diff --git a/packages/cli/internal/server/handlers/boxes.go b/packages/cli/internal/server/handlers/boxes.go new file mode 100644 index 00000000..8198c54c --- /dev/null +++ b/packages/cli/internal/server/handlers/boxes.go @@ -0,0 +1,83 @@ +package handlers + +import ( + "log" + "net/http" + + client "github.com/babelcloud/gbox/packages/cli/internal/client" +) + +// BoxHandlers handles box-related operations (proxy to remote GBOX API) +type BoxHandlers struct { + serverService ServerService +} + +// NewBoxHandlers creates a new box handlers instance +func NewBoxHandlers(serverSvc ServerService) *BoxHandlers { + return &BoxHandlers{ + serverService: serverSvc, + } +} + +// HandleBoxList handles /api/boxes endpoint - proxy to remote GBOX API +func (h *BoxHandlers) HandleBoxList(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse query parameters + query := req.URL.Query() + typeFilter := query.Get("type") // e.g., ?type=android + + // Create GBOX client from profile + sdkClient, err := client.NewClientFromProfile() + if err != nil { + log.Printf("Failed to create GBOX client: %v", err) + RespondJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "error": "Failed to initialize GBOX client", + }) + return + } + + // Call GBOX API to get real box list + boxesData, err := client.ListBoxesRawData(sdkClient, []string{}) + if err != nil { + log.Printf("Failed to list boxes from GBOX API: %v", err) + RespondJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "error": "Failed to fetch boxes from GBOX API", + }) + return + } + + // Convert to the expected format and add name field + var allBoxes []map[string]interface{} + for _, box := range boxesData { + // Add name field if not present (use ID as fallback) + if _, ok := box["name"]; !ok { + if id, ok := box["id"].(string); ok { + box["name"] = id + } + } + allBoxes = append(allBoxes, box) + } + + // Filter boxes by type if specified + var filteredBoxes []map[string]interface{} + if typeFilter != "" { + for _, box := range allBoxes { + if boxType, ok := box["type"].(string); ok && boxType == typeFilter { + filteredBoxes = append(filteredBoxes, box) + } + } + } else { + filteredBoxes = allBoxes + } + + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "boxes": filteredBoxes, + "filter": map[string]interface{}{ + "type": typeFilter, + }, + }) +} \ No newline at end of file diff --git a/packages/cli/internal/server/handlers/devices.go b/packages/cli/internal/server/handlers/devices.go new file mode 100644 index 00000000..bed10ff5 --- /dev/null +++ b/packages/cli/internal/server/handlers/devices.go @@ -0,0 +1,256 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "os/exec" + "strings" + + "github.com/gorilla/websocket" +) + +// DeviceHandlers contains handlers for device management +type DeviceHandlers struct { + serverService ServerService + upgrader websocket.Upgrader +} + +// 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 + }, + }, + } +} + +// 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 + } + + devices, err := h.getADBDevices() + 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 + } + + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "devices": devices, + "onDemandEnabled": true, + }) +} + +// HandleDeviceAction handles device action requests (connect/disconnect) +func (h *DeviceHandlers) HandleDeviceAction(w http.ResponseWriter, r *http.Request) { + // Parse URL path: /api/devices/{id}/{action} + path := strings.TrimPrefix(r.URL.Path, "/api/devices/") + parts := strings.Split(path, "/") + + if len(parts) < 2 { + RespondJSON(w, http.StatusBadRequest, map[string]interface{}{ + "success": false, + "error": "Invalid device action URL format", + }) + return + } + + deviceID := parts[0] + action := parts[1] + + switch action { + case "connect": + h.handleDeviceConnect(w, r, deviceID) + case "disconnect": + h.handleDeviceDisconnect(w, r, deviceID) + default: + RespondJSON(w, http.StatusBadRequest, map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("Unknown action: %s", action), + }) + } +} + +// HandleDeviceRegister handles device registration requests +func (h *DeviceHandlers) HandleDeviceRegister(w http.ResponseWriter, r *http.Request) { + // TODO: Implement device registration + RespondJSON(w, http.StatusNotImplemented, map[string]interface{}{ + "success": false, + "error": "Device registration not yet implemented", + }) +} + +// HandleDeviceUnregister handles device unregistration requests +func (h *DeviceHandlers) HandleDeviceUnregister(w http.ResponseWriter, r *http.Request) { + // TODO: Implement device unregistration + RespondJSON(w, http.StatusNotImplemented, map[string]interface{}{ + "success": false, + "error": "Device unregistration not yet implemented", + }) +} + +// 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) + } + } +} + +// Private helper methods + +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, + "device_id": 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, + "device_id": 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) +} + +func (h *DeviceHandlers) getADBDevices() ([]map[string]interface{}, error) { + cmd := exec.Command("adb", "devices", "-l") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to execute adb devices: %v", err) + } + + lines := strings.Split(string(output), "\n") + var devices []map[string]interface{} + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "List of devices") { + continue + } + + parts := strings.Fields(line) + if len(parts) >= 2 { + device := map[string]interface{}{ + "id": parts[0], + "status": parts[1], + } + + // Parse additional device info if available + if len(parts) > 2 { + for _, part := range parts[2:] { + if strings.Contains(part, ":") { + kv := strings.SplitN(part, ":", 2) + if len(kv) == 2 { + device[kv[0]] = kv[1] + } + } + } + } + + devices = append(devices, device) + } + } + + return devices, nil +} \ No newline at end of file diff --git a/packages/cli/internal/server/handlers/interfaces.go b/packages/cli/internal/server/handlers/interfaces.go new file mode 100644 index 00000000..d4e28ed6 --- /dev/null +++ b/packages/cli/internal/server/handlers/interfaces.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "io/fs" + "time" +) + +// ServerService defines the interface for server operations that handlers need +type ServerService interface { + // Status and info + IsRunning() bool + GetPort() int + GetUptime() time.Duration + GetBuildID() string + GetVersion() string + + // Services status + IsADBExposeRunning() bool + + // Bridge management + ListBridges() []string + CreateBridge(deviceSerial string) error + RemoveBridge(deviceSerial string) + GetBridge(deviceSerial string) (Bridge, bool) + + // Static file serving + GetStaticFS() fs.FS + FindLiveViewStaticPath() string + FindStaticPath() string + + // Server lifecycle + Stop() error + + // ADB Expose methods + StartPortForward(boxID string, localPorts, remotePorts []int) error + StopPortForward(boxID string) error + ListPortForwards() interface{} +} + +// Bridge defines the interface for device bridge operations +type Bridge interface { + // Control event handlers + HandleTouchEvent(msg map[string]interface{}) + HandleKeyEvent(msg map[string]interface{}) + HandleScrollEvent(msg map[string]interface{}) +} + diff --git a/packages/cli/internal/server/handlers/pages.go b/packages/cli/internal/server/handlers/pages.go new file mode 100644 index 00000000..2f966cfd --- /dev/null +++ b/packages/cli/internal/server/handlers/pages.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "io" + "io/fs" + "net/http" + "time" +) + +// PagesHandlers contains handlers for page routes (/, /live-view, /adb-expose) +type PagesHandlers struct{ + staticFS fs.FS // Static files filesystem +} + +// NewPagesHandlers creates a new pages handlers instance +func NewPagesHandlers(staticFS fs.FS) *PagesHandlers { + return &PagesHandlers{ + staticFS: staticFS, + } +} + +// HandleLiveView handles /live-view and /live-view/ +func (h *PagesHandlers) HandleLiveView(w http.ResponseWriter, req *http.Request) { + // Redirect to built live-view app if available, otherwise serve fallback + h.serveLiveViewPage(w, req) +} + + +// serveLiveViewPage serves the live-view page +func (h *PagesHandlers) serveLiveViewPage(w http.ResponseWriter, req *http.Request) { + // Try to serve embedded live-view index.html + if h.staticFS != nil { + file, err := h.staticFS.Open("static/live-view/index.html") + if err == nil { + defer file.Close() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeContent(w, req, "index.html", time.Time{}, file.(io.ReadSeeker)) + return + } + } + + // Fallback error message + http.Error(w, "Live view not available. Please rebuild with embedded static files.", http.StatusNotFound) +} + +// HandleADBExpose handles /adb-expose and /adb-expose/ +func (h *PagesHandlers) HandleADBExpose(w http.ResponseWriter, req *http.Request) { + // Try to serve adb-expose.html from static files + if h.staticFS != nil { + file, err := h.staticFS.Open("static/adb-expose.html") + if err == nil { + defer file.Close() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeContent(w, req, "adb-expose.html", time.Time{}, file.(io.ReadSeeker)) + return + } + } + + // Fallback error + http.Error(w, "ADB expose page not available", http.StatusNotFound) +} + +// HandleRoot handles / (root path) +func (h *PagesHandlers) HandleRoot(w http.ResponseWriter, req *http.Request) { + // Only handle exact root path + if req.URL.Path != "/" { + http.NotFound(w, req) + return + } + + // Try to serve index.html from static files + if h.staticFS != nil { + // Try to serve index.html from static subdirectory + file, err := h.staticFS.Open("static/index.html") + if err == nil { + defer file.Close() + // Set content type + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Copy file content to response + http.ServeContent(w, req, "index.html", time.Time{}, file.(io.ReadSeeker)) + return + } + } + + // Fallback: simple status page + http.Error(w, "Static files not available", http.StatusNotFound) +} \ No newline at end of file diff --git a/packages/cli/internal/server/handlers/streaming.go b/packages/cli/internal/server/handlers/streaming.go new file mode 100644 index 00000000..d4d77964 --- /dev/null +++ b/packages/cli/internal/server/handlers/streaming.go @@ -0,0 +1,1547 @@ +package handlers + +import ( + "fmt" + "log" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/h264" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/mse" + "github.com/gorilla/websocket" +) + +var controlUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins for now + }, +} + +// StreamingHandlers contains handlers for streaming routes +type StreamingHandlers struct { + // We'll pass necessary dependencies when needed + serverService ServerService // Access to bridge manager through interface + webrtcHandlers *WebRTCHandlers // WebRTC signaling handler + pathPrefix string // URL path prefix for responses +} + +// NewStreamingHandlers creates a new streaming handlers instance +func NewStreamingHandlers() *StreamingHandlers { + return &StreamingHandlers{} +} + +// SetServerService sets the server service dependency +func (h *StreamingHandlers) SetServerService(service ServerService) { + h.serverService = service + h.webrtcHandlers = NewWebRTCHandlers(service) +} + +// SetPathPrefix sets the URL path prefix for responses +func (h *StreamingHandlers) SetPathPrefix(prefix string) { + h.pathPrefix = prefix +} + +// buildURL constructs a full URL with the correct prefix +func (h *StreamingHandlers) buildURL(path string) string { + if h.pathPrefix != "" { + return h.pathPrefix + path + } + return path +} + +// HandleVideoStream handles H.264 and MSE video streaming +func (h *StreamingHandlers) HandleVideoStream(w http.ResponseWriter, r *http.Request) { + // Extract device serial from path + path := strings.TrimPrefix(r.URL.Path, "/stream/video/") + parts := strings.Split(path, "?") + deviceSerial := parts[0] + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + // Parse mode and format parameters + mode := r.URL.Query().Get("mode") + format := r.URL.Query().Get("format") + + 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, r) + } else { + // Direct H.264 streaming (Annex-B format) + handler := h264.NewHTTPHandler(deviceSerial) + handler.ServeHTTP(w, r) + } + + case "mse": + // MSE fMP4 streaming + transport := mse.NewTransport(deviceSerial) + transport.ServeHTTP(w, r) + + default: + http.Error(w, "Invalid mode. Supported: h264, mse", http.StatusBadRequest) + } +} + +// HandleVideoWebSocket handles WebSocket video streaming (consolidated endpoint) +func (h *StreamingHandlers) HandleVideoWebSocket(w http.ResponseWriter, r *http.Request) { + // Extract device serial from path /stream/video/ws/{device} + path := strings.TrimPrefix(r.URL.Path, "/stream/video/ws/") + parts := strings.Split(path, "?") + deviceSerial := parts[0] + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + if !isValidDeviceSerial(deviceSerial) { + http.Error(w, "Invalid device serial", http.StatusBadRequest) + return + } + + // Use H.264 WebSocket handler + handler := h264.NewWSHandler(deviceSerial) + handler.ServeWebSocket(w, r) +} + +// HandleAudioStream handles audio streaming endpoints +func (h *StreamingHandlers) HandleAudioStream(w http.ResponseWriter, r *http.Request) { + // Extract device serial from path + path := strings.TrimPrefix(r.URL.Path, "/stream/audio/") + parts := strings.Split(path, "?") + deviceSerial := parts[0] + + 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 parameter + codec := r.URL.Query().Get("codec") + if codec == "" { + codec = "aac" // Default to AAC + } + + switch codec { + case "aac": + // AAC codec (default - placeholder) + h.handleOpusAudioHTTP(w, r, deviceSerial) + + case "opus": + // Opus codec + h.handleOpusAudioHTTP(w, r, deviceSerial) + + default: + http.Error(w, "Invalid codec. Supported: aac, opus", http.StatusBadRequest) + } +} + +// HandleStreamInfo provides information about available streams +func (h *StreamingHandlers) HandleStreamInfo(w http.ResponseWriter, r *http.Request) { + deviceSerial := r.URL.Query().Get("device") + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + // TODO: We need to access bridge manager here - will fix this in next step + // For now, return basic info + response := map[string]interface{}{ + "device": deviceSerial, + "supportedModes": []string{"h264", "mse", "webrtc"}, + "supportedFormats": map[string][]string{ + "h264": {"annexb", "avc"}, + "mse": {"fmp4"}, + }, + "endpoints": map[string]string{ + "video": h.buildURL(fmt.Sprintf("/stream/video/%s", deviceSerial)), + "video_h264": h.buildURL(fmt.Sprintf("/stream/video/%s?mode=h264", deviceSerial)), + "video_h264_avc": h.buildURL(fmt.Sprintf("/stream/video/%s?mode=h264&format=avc", deviceSerial)), + "video_mse": h.buildURL(fmt.Sprintf("/stream/video/%s?mode=mse", deviceSerial)), + "video_ws": h.buildURL(fmt.Sprintf("/stream/video/ws/%s", deviceSerial)), + "audio": h.buildURL(fmt.Sprintf("/stream/audio/%s", deviceSerial)), + "audio_aac": h.buildURL(fmt.Sprintf("/stream/audio/%s?codec=aac", deviceSerial)), + "audio_opus": h.buildURL(fmt.Sprintf("/stream/audio/%s?codec=opus", deviceSerial)), + "control": h.buildURL(fmt.Sprintf("/stream/control/%s", deviceSerial)), + "webrtc": "/webrtc/signaling", // WebRTC uses signaling endpoint + }, + } + + w.Header().Set("Content-Type", "application/json") + RespondJSON(w, http.StatusOK, response) +} + +// HandleStreamConnect handles stream connection requests +func (h *StreamingHandlers) HandleStreamConnect(w http.ResponseWriter, r *http.Request) { + // Extract device serial from path like /api/stream/{device}/connect + path := strings.TrimPrefix(r.URL.Path, "/api/stream/") + parts := strings.Split(path, "/") + + if len(parts) < 2 { + http.Error(w, "Invalid stream URL format", http.StatusBadRequest) + return + } + + deviceSerial := parts[0] + action := parts[1] // "connect" or "disconnect" + + if !isValidDeviceSerial(deviceSerial) { + http.Error(w, "Invalid device serial", http.StatusBadRequest) + return + } + + switch action { + case "connect": + h.handleStreamConnectDevice(w, r, deviceSerial) + case "disconnect": + h.handleStreamDisconnectDevice(w, r, deviceSerial) + default: + http.Error(w, fmt.Sprintf("Invalid action: %s", action), http.StatusBadRequest) + } +} + +// handleStreamConnectDevice handles device connection for streaming +func (h *StreamingHandlers) handleStreamConnectDevice(w http.ResponseWriter, r *http.Request, deviceSerial string) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // TODO: Need to access bridge manager - will implement properly later + response := map[string]interface{}{ + "success": true, + "message": "Stream connection not yet fully implemented in new architecture", + "device": deviceSerial, + "streamActive": false, + } + w.Header().Set("Content-Type", "application/json") + RespondJSON(w, http.StatusOK, response) +} + +// handleStreamDisconnectDevice handles device disconnection for streaming +func (h *StreamingHandlers) handleStreamDisconnectDevice(w http.ResponseWriter, r *http.Request, deviceSerial string) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // TODO: Need to access bridge manager - will implement properly later + response := map[string]interface{}{ + "success": true, + "message": "Stream disconnection not yet fully implemented in new architecture", + "device": deviceSerial, + "streamActive": false, + } + w.Header().Set("Content-Type", "application/json") + RespondJSON(w, http.StatusOK, response) +} + +// HandleControlWebSocket handles WebSocket connections for device control +func (h *StreamingHandlers) HandleControlWebSocket(w http.ResponseWriter, r *http.Request) { + // Extract device serial from path like /stream/control/{device} + path := strings.TrimPrefix(r.URL.Path, "/stream/control/") + parts := strings.Split(path, "?") + deviceSerial := parts[0] + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + if !isValidDeviceSerial(deviceSerial) { + http.Error(w, "Invalid device serial", http.StatusBadRequest) + return + } + + conn, err := controlUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("Failed to upgrade control WebSocket: %v", err) + return + } + defer conn.Close() + + log.Printf("Control WebSocket connection established for device: %s", deviceSerial) + + // 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("Control WebSocket closed normally for device: %s", deviceSerial) + } else if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("Control WebSocket read error: %v", err) + } + break + } + + msgType, ok := msg["type"].(string) + if !ok { + continue + } + + log.Printf("Control message received: type=%s, device=%s", msgType, deviceSerial) + + switch msgType { + // WebRTC signaling messages - delegate to WebRTC handler + case "ping", "offer", "answer", "ice-candidate": + if h.webrtcHandlers != nil { + h.delegateToWebRTCHandler(conn, msg, msgType, deviceSerial) + } else { + log.Printf("WebRTC handlers not initialized") + } + + // Device control messages + case "touch": + h.handleTouchMessage(conn, msg, deviceSerial) + + case "key": + h.handleKeyMessage(conn, msg, deviceSerial) + + case "scroll": + h.handleScrollMessage(conn, msg, deviceSerial) + + case "clipboard_set": + h.handleClipboardMessage(conn, msg, deviceSerial) + + case "reset_video": + h.handleResetVideoMessage(conn, msg, deviceSerial) + + default: + log.Printf("Unknown control message type: %s", msgType) + } + } +} + +// delegateToWebRTCHandler forwards WebRTC signaling messages to the specialized handler +func (h *StreamingHandlers) delegateToWebRTCHandler(conn *websocket.Conn, msg map[string]interface{}, msgType, deviceSerial string) { + 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) + } +} + +// Helper function to parse device serial from various URL formats +func extractDeviceSerial(path string) string { + // Remove common prefixes + path = strings.TrimPrefix(path, "/stream/video/") + path = strings.TrimPrefix(path, "/stream/ws/") + path = strings.TrimPrefix(path, "/api/v1/devices/") + + // Split by / and ? to get just the device serial + parts := strings.FieldsFunc(path, func(c rune) bool { + return c == '/' || c == '?' + }) + + if len(parts) > 0 { + return parts[0] + } + + return "" +} + +// Helper function to validate device serial format +func isValidDeviceSerial(serial string) bool { + if serial == "" { + return false + } + + // Basic validation - should be alphanumeric with possible special chars + if len(serial) < 1 || len(serial) > 64 { + return false + } + + // Allow alphanumeric, dots, dashes, underscores + for _, c := range serial { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_') { + return false + } + } + + return true +} + +// Helper function to parse quality parameter +func parseQuality(qualityStr string) int { + if qualityStr == "" { + return 80 // Default quality + } + + quality, err := strconv.Atoi(qualityStr) + if err != nil || quality < 1 || quality > 100 { + return 80 // Default on invalid input + } + + return quality +} + +// Control message handlers + +func (h *StreamingHandlers) handleTouchMessage(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { + action, _ := msg["action"].(string) + x, _ := msg["x"].(float64) + y, _ := msg["y"].(float64) + pressure, _ := msg["pressure"].(float64) + pointerId, _ := msg["pointerId"].(float64) + + log.Printf("Touch event: device=%s, action=%s, x=%.3f, y=%.3f, pressure=%.2f, pointerId=%.0f", + deviceSerial, action, x, y, pressure, pointerId) + + // Forward touch event to bridge manager + if h.serverService != nil { + bridge, exists := h.serverService.GetBridge(deviceSerial) + if exists && bridge != nil { + bridge.HandleTouchEvent(msg) + } else { + log.Printf("Bridge not found for device %s", deviceSerial) + } + } +} + +func (h *StreamingHandlers) handleKeyMessage(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { + action, _ := msg["action"].(string) + keycode, _ := msg["keycode"].(float64) + metaState, _ := msg["metaState"].(float64) + + log.Printf("Key event: device=%s, action=%s, keycode=%.0f, metaState=%.0f", + deviceSerial, action, keycode, metaState) + + // Forward key event to bridge manager + if h.serverService != nil { + bridge, exists := h.serverService.GetBridge(deviceSerial) + if exists && bridge != nil { + bridge.HandleKeyEvent(msg) + } else { + log.Printf("Bridge not found for device %s", deviceSerial) + } + } +} + +func (h *StreamingHandlers) handleScrollMessage(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { + x, _ := msg["x"].(float64) + y, _ := msg["y"].(float64) + hScroll, _ := msg["hScroll"].(float64) + vScroll, _ := msg["vScroll"].(float64) + + log.Printf("Scroll event: device=%s, x=%.3f, y=%.3f, hScroll=%.2f, vScroll=%.2f", + deviceSerial, x, y, hScroll, vScroll) + + // Forward scroll event to bridge manager + if h.serverService != nil { + bridge, exists := h.serverService.GetBridge(deviceSerial) + if exists && bridge != nil { + bridge.HandleScrollEvent(msg) + } else { + log.Printf("Bridge not found for device %s", deviceSerial) + } + } +} + +func (h *StreamingHandlers) handleClipboardMessage(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { + text, _ := msg["text"].(string) + paste, _ := msg["paste"].(bool) + + log.Printf("Clipboard event: device=%s, text_length=%d, paste=%t", + deviceSerial, len(text), paste) + + // Clipboard handling - currently not implemented in bridge, so just log + // TODO: Implement clipboard handling when bridge supports it + log.Printf("Clipboard message received but bridge clipboard handling not yet implemented") +} + +func (h *StreamingHandlers) handleResetVideoMessage(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { + log.Printf("Reset video event: device=%s", deviceSerial) + + // Video reset handling - currently not implemented in bridge, so just log + // TODO: Implement video reset handling when bridge supports it + log.Printf("Reset video message received but bridge video reset handling not yet implemented") +} + +// handleOpusAudioStream handles Opus audio WebSocket connections +func (h *StreamingHandlers) handleOpusAudioStream(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := slog.With("device", deviceSerial) + logger.Info("🎵 Starting Opus audio WebSocket stream", "url", r.URL.String()) + + // Upgrade to WebSocket + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins for now + }, + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + logger.Error("❌ Failed to upgrade to WebSocket", "error", err) + return + } + defer conn.Close() + + logger.Info("✅ Opus audio WebSocket connection established") + + // Get audio stream from device source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + logger.Error("❌ Device source not found - is scrcpy running for this device?") + conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "Device not connected")) + return + } + + logger.Info("✅ Found scrcpy source for audio streaming") + + // Subscribe to audio stream + subscriberID := fmt.Sprintf("audio_ws_%p", conn) + audioCh := source.SubscribeAudio(subscriberID, 100) + defer source.UnsubscribeAudio(subscriberID) + + logger.Info("Subscribed to audio stream", "subscriberID", subscriberID) + + // Handle client messages in separate goroutine + go func() { + for { + _, _, err := conn.ReadMessage() + if err != nil { + logger.Info("Audio WebSocket client disconnected", "error", err) + return + } + } + }() + + // Stream audio data to client + audioFrameCount := 0 + logger.Info("🎵 Starting to stream audio data to WebSocket client") + + for audioSample := range audioCh { + audioFrameCount++ + + // Log first few frames to verify we're receiving audio data + if audioFrameCount <= 5 || audioFrameCount%50 == 0 { + logger.Info("🎵 Received audio sample", "frame", audioFrameCount, "size", len(audioSample.Data)) + } + + // Send Opus data wrapped in minimal OGG container for browser compatibility + if len(audioSample.Data) > 0 { + // Check if client requested OGG format + format := r.URL.Query().Get("format") + var dataToSend []byte + + if format == "ogg" { + // Wrap Opus data in minimal OGG page + dataToSend = h.wrapOpusInOgg(audioSample.Data, audioFrameCount) + } else { + // Send raw Opus data + dataToSend = audioSample.Data + } + + err := conn.WriteMessage(websocket.BinaryMessage, dataToSend) + if err != nil { + logger.Error("❌ Failed to send audio data to WebSocket", "error", err, "frame", audioFrameCount) + break + } + + // Log successful transmission for first few frames + if audioFrameCount <= 5 { + logger.Info("✅ Successfully sent audio data to WebSocket", "frame", audioFrameCount, "size", len(dataToSend), "format", format) + } + } else { + logger.Warn("⚠️ Received empty audio sample", "frame", audioFrameCount) + } + } + + logger.Info("🎵 Audio stream ended", "totalFrames", audioFrameCount) +} + +// wrapOpusInOgg wraps Opus audio data in a minimal OGG container for browser playback +func (h *StreamingHandlers) wrapOpusInOgg(opusData []byte, frameNumber int) []byte { + // Send only OpusHead for first frame + if frameNumber == 1 { + return h.createOpusHead() + } + + // Send only OpusTags for second frame + if frameNumber == 2 { + return h.createOpusTags() + } + + // For frame 3 onwards, send audio data + if frameNumber >= 3 { + return h.createOpusAudioPage(opusData, frameNumber) // Use frame number directly for proper sequencing + } + + // Should never reach here + return []byte{} +} + +// createOpusAudioPage creates an OGG page containing Opus audio data +func (h *StreamingHandlers) createOpusAudioPage(opusData []byte, pageSeq int) []byte { + segmentLength := len(opusData) + if segmentLength > 255 { + segmentLength = 255 // OGG segment max length + opusData = opusData[:255] + } + + // Calculate granule position (cumulative sample position) + // For 20ms frames at 48kHz: each frame = 960 samples + // Page sequence starts at 3 for audio data (after OpusHead=0, OpusTags=1) + granulePos := uint64(pageSeq-2) * 960 // pageSeq 3 -> granule 960, pageSeq 4 -> granule 1920, etc. + + oggHeader := []byte{ + 0x4F, 0x67, 0x67, 0x53, // "OggS" magic signature + 0x00, // Version + 0x00, // Header type (0x00 = continuation) + byte(granulePos), byte(granulePos >> 8), byte(granulePos >> 16), byte(granulePos >> 24), // Granule position (lower 4 bytes) + byte(granulePos >> 32), byte(granulePos >> 40), byte(granulePos >> 48), byte(granulePos >> 56), // Granule position (upper 4 bytes) + 0x01, 0x00, 0x00, 0x00, // Serial number (4 bytes) - stream 1 + byte(pageSeq & 0xFF), byte((pageSeq >> 8) & 0xFF), byte((pageSeq >> 16) & 0xFF), byte((pageSeq >> 24) & 0xFF), // Page sequence number + 0x00, 0x00, 0x00, 0x00, // CRC checksum (4 bytes) - calculated below + 0x01, // Number of page segments + byte(segmentLength), // Segment table + } + + // Combine header with data + result := make([]byte, len(oggHeader)+len(opusData)) + copy(result, oggHeader) + copy(result[len(oggHeader):], opusData) + + // Calculate and set CRC checksum + crc := h.calculateOggCRC(result) + result[22] = byte(crc & 0xFF) + result[23] = byte((crc >> 8) & 0xFF) + result[24] = byte((crc >> 16) & 0xFF) + result[25] = byte((crc >> 24) & 0xFF) + + return result +} + +// createOpusHead creates the OpusHead identification header +func (h *StreamingHandlers) createOpusHead() []byte { + opusHead := []byte{ + 0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead" + 0x01, // Version + 0x02, // Channel count (stereo) + 0x00, 0x00, // Pre-skip (0 samples) - little endian, let decoder handle + 0x80, 0xBB, 0x00, 0x00, // Sample rate (48000 Hz) - little endian + 0x00, 0x00, // Output gain (0 dB) - little endian + 0x00, // Channel mapping family (0 = RTP mapping) + } + + // Wrap in OGG page + oggHeader := []byte{ + 0x4F, 0x67, 0x67, 0x53, // "OggS" + 0x00, // Version + 0x02, // Header type (beginning of stream) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Granule position + 0x01, 0x00, 0x00, 0x00, // Serial number + 0x00, 0x00, 0x00, 0x00, // Page sequence (0) + 0x00, 0x00, 0x00, 0x00, // CRC + 0x01, // Segments + byte(len(opusHead)), // Segment length + } + + result := make([]byte, len(oggHeader)+len(opusHead)) + copy(result, oggHeader) + copy(result[len(oggHeader):], opusHead) + + // Calculate and set CRC + crc := h.calculateOggCRC(result) + result[22] = byte(crc & 0xFF) + result[23] = byte((crc >> 8) & 0xFF) + result[24] = byte((crc >> 16) & 0xFF) + result[25] = byte((crc >> 24) & 0xFF) + + return result +} + +// createOpusTags creates the OpusTags comment header +func (h *StreamingHandlers) createOpusTags() []byte { + vendor := "libopus" + opusTags := make([]byte, 0) + + // OpusTags header + opusTags = append(opusTags, []byte("OpusTags")...) + + // Vendor string length (little endian) + vendorLen := len(vendor) + opusTags = append(opusTags, byte(vendorLen), byte(vendorLen>>8), byte(vendorLen>>16), byte(vendorLen>>24)) + + // Vendor string + opusTags = append(opusTags, vendor...) + + // User comment list length (0 comments) + opusTags = append(opusTags, 0x00, 0x00, 0x00, 0x00) + + // Wrap in OGG page + oggHeader := []byte{ + 0x4F, 0x67, 0x67, 0x53, // "OggS" + 0x00, // Version + 0x00, // Header type (continuation) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Granule position + 0x01, 0x00, 0x00, 0x00, // Serial number + 0x01, 0x00, 0x00, 0x00, // Page sequence (1) + 0x00, 0x00, 0x00, 0x00, // CRC + 0x01, // Segments + byte(len(opusTags)), // Segment length + } + + result := make([]byte, len(oggHeader)+len(opusTags)) + copy(result, oggHeader) + copy(result[len(oggHeader):], opusTags) + + // Calculate and set CRC + crc := h.calculateOggCRC(result) + result[22] = byte(crc & 0xFF) + result[23] = byte((crc >> 8) & 0xFF) + result[24] = byte((crc >> 16) & 0xFF) + result[25] = byte((crc >> 24) & 0xFF) + + return result +} + +// OGG CRC lookup table +var oggCRCTable = [256]uint32{ + 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, 0x130476dc, 0x17c56b6b, + 0x1a864db2, 0x1e475005, 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, + 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd, 0x4c11db70, 0x48d0c6c7, + 0x4593e01e, 0x4152fda9, 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75, + 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011, 0x791d4014, 0x7ddc5da3, + 0x709f7b7a, 0x745e66cd, 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039, + 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5, 0xbe2b5b58, 0xbaea46ef, + 0xb7a96036, 0xb3687d81, 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d, + 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49, 0xc7361b4c, 0xc3f706fb, + 0xceb42022, 0xca753d95, 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1, + 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d, 0x34867077, 0x30476dc0, + 0x3d044b19, 0x39c556ae, 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072, + 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16, 0x018aeb13, 0x054bf6a4, + 0x0808d07d, 0x0cc9cdca, 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde, + 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02, 0x5e9f46bf, 0x5a5e5b08, + 0x571d7dd1, 0x53dc6066, 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba, + 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e, 0xbfa1b04b, 0xbb60adfc, + 0xb6238b25, 0xb2e29692, 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6, + 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a, 0xe0b41de7, 0xe4750050, + 0xe9362689, 0xedf73b3e, 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2, + 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686, 0xd5b88683, 0xd1799b34, + 0xdc3abded, 0xd8fba05a, 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637, + 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb, 0x4f040d56, 0x4bc510e1, + 0x46863638, 0x42472b8f, 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53, + 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47, 0x36194d42, 0x32d850f5, + 0x3f9b762c, 0x3b5a6b9b, 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff, + 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623, 0xf12f560e, 0xf5ee4bb9, + 0xf8ad6d60, 0xfc6c70d7, 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b, + 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f, 0xc423cd6a, 0xc0e2d0dd, + 0xcda1f604, 0xc960ebb3, 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7, + 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b, 0x9b3660c6, 0x9ff77d71, + 0x92b45ba8, 0x9675461f, 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3, + 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640, 0x4e8ee645, 0x4a4ffbf2, + 0x470cdd2b, 0x43cdc09c, 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8, + 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24, 0x119b4be9, 0x155a565e, + 0x18197087, 0x1cd86d30, 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec, + 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088, 0x2497d08d, 0x2056cd3a, + 0x2d15ebe3, 0x29d4f654, 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0, + 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c, 0xe3a1cbc1, 0xe760d676, + 0xea23f0af, 0xeee2ed18, 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4, + 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0, 0x9abc8bd5, 0x9e7d9662, + 0x933eb0bb, 0x97ffad0c, 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668, + 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4, +} + +// calculateOggCRC calculates proper OGG CRC-32 checksum +func (h *StreamingHandlers) calculateOggCRC(data []byte) uint32 { + crc := uint32(0) + + // Set CRC field to 0 before calculation + if len(data) >= 26 { + data[22] = 0 + data[23] = 0 + data[24] = 0 + data[25] = 0 + } + + for _, b := range data { + crc = (crc << 8) ^ oggCRCTable[((crc>>24)^uint32(b))&0xFF] + } + + return crc +} + +// createTestOGG creates a minimal test OGG file with silence to validate format +func (h *StreamingHandlers) createTestOGG() []byte { + // Create OpusHead page + opusHead := h.createOpusHead() + + // Create OpusTags page + opusTags := h.createOpusTags() + + // Create a few silence frames (Opus silence frame is just TOC byte + minimal data) + silenceFrame := []byte{0xFC, 0x00} // TOC byte + minimal silence data + + // Create audio pages with silence + audioPage1 := h.createOpusAudioPage(silenceFrame, 2) + audioPage2 := h.createOpusAudioPage(silenceFrame, 3) + audioPage3 := h.createOpusAudioPage(silenceFrame, 4) + + // Combine all pages + totalLen := len(opusHead) + len(opusTags) + len(audioPage1) + len(audioPage2) + len(audioPage3) + result := make([]byte, totalLen) + + offset := 0 + copy(result[offset:], opusHead) + offset += len(opusHead) + + copy(result[offset:], opusTags) + offset += len(opusTags) + + copy(result[offset:], audioPage1) + offset += len(audioPage1) + + copy(result[offset:], audioPage2) + offset += len(audioPage2) + + copy(result[offset:], audioPage3) + + return result +} + +// handleOpusAudioHTTP handles HTTP-based Opus audio streaming for direct browser testing +func (h *StreamingHandlers) handleOpusAudioHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := slog.With("device", deviceSerial) + logger.Info("🎵 Starting Opus audio HTTP stream", "url", r.URL.String()) + + // Set headers for audio streaming + format := r.URL.Query().Get("format") + saveFile := r.URL.Query().Get("save") == "true" + debug := r.URL.Query().Get("debug") == "true" + test := r.URL.Query().Get("test") == "true" + + if test { + // Send a minimal test OGG file to validate our format + w.Header().Set("Content-Type", "audio/ogg; codecs=opus") + logger.Info("🧪 Serving test OGG file") + testData := h.createTestOGG() + w.Write(testData) + return + } else if debug { + w.Header().Set("Content-Type", "text/plain") + logger.Info("🔍 Serving debug mode - will show hex dump of first 10 frames") + } else if format == "ogg" { + if saveFile { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=\"audio.ogg\"") + logger.Info("🎵 Serving OGG/Opus as download") + } else { + w.Header().Set("Content-Type", "audio/ogg; codecs=opus") + logger.Info("🎵 Serving OGG/Opus format") + } + } else { + if saveFile { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=\"audio.opus\"") + logger.Info("🎵 Serving raw Opus as download") + } else { + w.Header().Set("Content-Type", "audio/opus") + logger.Info("🎵 Serving raw Opus format") + } + } + + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Get audio stream from device source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + logger.Error("❌ Device source not found - is scrcpy running for this device?") + http.Error(w, "Device not connected", http.StatusServiceUnavailable) + return + } + + logger.Info("✅ Found scrcpy source for HTTP audio streaming") + + // Subscribe to audio stream + subscriberID := fmt.Sprintf("audio_http_%p", w) + audioCh := source.SubscribeAudio(subscriberID, 100) + defer source.UnsubscribeAudio(subscriberID) + + logger.Info("🎵 Subscribed to audio stream", "subscriberID", subscriberID) + + // Stream audio data to client + audioFrameCount := 0 + logger.Info("🎵 Starting to stream audio data to HTTP client") + + for { + select { + case <-r.Context().Done(): + logger.Info("🎵 HTTP audio stream context cancelled") + return + + case audioSample, ok := <-audioCh: + if !ok { + logger.Info("🎵 HTTP audio channel closed") + return + } + + audioFrameCount++ + + // Log first few frames to verify we're receiving audio data + if audioFrameCount <= 5 || audioFrameCount%50 == 0 { + logger.Info("🎵 Received audio sample for HTTP", "frame", audioFrameCount, "size", len(audioSample.Data)) + } + + // Debug: Log raw data for first few frames to understand format + if audioFrameCount <= 3 && len(audioSample.Data) > 0 { + hexData := "" + dataLen := len(audioSample.Data) + if dataLen > 16 { + dataLen = 16 + } + for i, b := range audioSample.Data[:dataLen] { + if i > 0 { + hexData += " " + } + hexData += fmt.Sprintf("%02x", b) + } + logger.Info("🔍 Raw audio data", "frame", audioFrameCount, "hex", hexData) + } + + // Send audio data + if len(audioSample.Data) > 0 { + var dataToSend []byte + + if debug { + // Debug mode: output hex dump of first 10 frames + if audioFrameCount <= 10 { + debugText := fmt.Sprintf("Frame %d (size %d bytes):\n", audioFrameCount, len(audioSample.Data)) + + // Add hex dump + dataLen := len(audioSample.Data) + if dataLen > 64 { + dataLen = 64 // Limit to first 64 bytes + } + + for i := 0; i < dataLen; i += 16 { + end := i + 16 + if end > dataLen { + end = dataLen + } + + // Hex part + hexPart := "" + for j := i; j < end; j++ { + hexPart += fmt.Sprintf("%02x ", audioSample.Data[j]) + } + // Pad hex part to 48 characters (16 * 3) + for len(hexPart) < 48 { + hexPart += " " + } + + // ASCII part + asciiPart := "" + for j := i; j < end; j++ { + if audioSample.Data[j] >= 32 && audioSample.Data[j] <= 126 { + asciiPart += string(audioSample.Data[j]) + } else { + asciiPart += "." + } + } + + debugText += fmt.Sprintf("%04x: %s |%s|\n", i, hexPart, asciiPart) + } + debugText += "\n" + + if _, err := w.Write([]byte(debugText)); err != nil { + logger.Error("❌ Failed to write debug data", "error", err) + return + } + } + + // Stop after 10 frames in debug mode + if audioFrameCount >= 10 { + return + } + } else if format == "ogg" { + // Wrap Opus data in minimal OGG page + dataToSend = h.wrapOpusInOgg(audioSample.Data, audioFrameCount) + + // Debug: Log first few OGG pages + if audioFrameCount <= 3 { + logger.Info("🔧 OGG page created", "frame", audioFrameCount, "size", len(dataToSend)) + if len(dataToSend) >= 4 { + header := string(dataToSend[0:4]) + logger.Info("🔧 OGG page header", "frame", audioFrameCount, "header", header) + } + } + } else { + // Send raw Opus data + dataToSend = audioSample.Data + } + + if !debug { + if _, err := w.Write(dataToSend); err != nil { + logger.Error("❌ Failed to write audio data to HTTP response", "error", err, "frame", audioFrameCount) + return + } + + // Flush data immediately for low latency + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + // Log successful transmission for first few frames + if audioFrameCount <= 5 { + logger.Info("✅ Successfully sent audio data to HTTP client", "frame", audioFrameCount, "size", len(dataToSend), "format", format) + } + } + } else { + logger.Warn("⚠️ Received empty audio sample for HTTP", "frame", audioFrameCount) + } + } + } +} + +// handleRTPOpusHTTP handles RTP-wrapped Opus audio streaming for FFmpeg compatibility +func (h *StreamingHandlers) handleRTPOpusHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := slog.With("device", deviceSerial) + logger.Info("🎵 Starting RTP/Opus audio HTTP stream", "url", r.URL.String()) + + // Set headers for RTP streaming + w.Header().Set("Content-Type", "application/rtp") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Get audio stream from device source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + logger.Error("❌ Device source not found - is scrcpy running for this device?") + http.Error(w, "Device not connected", http.StatusServiceUnavailable) + return + } + + logger.Info("✅ Found scrcpy source for RTP/Opus streaming") + + // Subscribe to audio stream + subscriberID := fmt.Sprintf("audio_rtp_%p", w) + audioCh := source.SubscribeAudio(subscriberID, 100) + defer source.UnsubscribeAudio(subscriberID) + + logger.Info("🎵 Subscribed to RTP/Opus stream", "subscriberID", subscriberID) + + // Stream RTP-wrapped audio data to client + audioFrameCount := 0 + sequenceNumber := uint16(1) + timestamp := uint32(0) + ssrc := uint32(0x12345678) // Random SSRC + + logger.Info("🎵 Starting to stream RTP/Opus data to HTTP client") + + for { + select { + case <-r.Context().Done(): + logger.Info("🎵 RTP/Opus stream context cancelled") + return + + case audioSample, ok := <-audioCh: + if !ok { + logger.Info("🎵 RTP/Opus channel closed") + return + } + + audioFrameCount++ + + // Log first few frames to verify we're receiving audio data + if audioFrameCount <= 5 || audioFrameCount%50 == 0 { + logger.Info("🎵 Received audio sample for RTP", "frame", audioFrameCount, "size", len(audioSample.Data)) + } + + // Wrap Opus data in RTP packet + if len(audioSample.Data) > 0 { + rtpPacket := h.createRTPPacket(audioSample.Data, sequenceNumber, timestamp, ssrc) + + if _, err := w.Write(rtpPacket); err != nil { + logger.Error("❌ Failed to write RTP data to HTTP response", "error", err, "frame", audioFrameCount) + return + } + + // Flush data immediately for low latency + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + // Update RTP sequence and timestamp + sequenceNumber++ + timestamp += 960 // 20ms at 48kHz = 960 samples + + // Log successful transmission for first few frames + if audioFrameCount <= 5 { + logger.Info("✅ Successfully sent RTP/Opus data to HTTP client", "frame", audioFrameCount, "size", len(rtpPacket)) + } + } else { + logger.Warn("⚠️ Received empty audio sample for RTP", "frame", audioFrameCount) + } + } + } +} + +// createRTPPacket creates an RTP packet for Opus audio data +func (h *StreamingHandlers) createRTPPacket(opusData []byte, seqNum uint16, timestamp uint32, ssrc uint32) []byte { + // RTP header: 12 bytes + // V(2) P(1) X(1) CC(4) = 0x80 (version 2, no padding, no extension, no CSRC) + // M(1) PT(7) = 0x60 (marker=0, payload type 96 for dynamic Opus) + // Sequence number (2 bytes) + // Timestamp (4 bytes) + // SSRC (4 bytes) + + rtpHeader := make([]byte, 12) + rtpHeader[0] = 0x80 // V=2, P=0, X=0, CC=0 + rtpHeader[1] = 96 // M=0, PT=96 (dynamic payload type for Opus) + + // Sequence number (big endian) + rtpHeader[2] = byte(seqNum >> 8) + rtpHeader[3] = byte(seqNum & 0xFF) + + // Timestamp (big endian) + rtpHeader[4] = byte(timestamp >> 24) + rtpHeader[5] = byte(timestamp >> 16) + rtpHeader[6] = byte(timestamp >> 8) + rtpHeader[7] = byte(timestamp & 0xFF) + + // SSRC (big endian) + rtpHeader[8] = byte(ssrc >> 24) + rtpHeader[9] = byte(ssrc >> 16) + rtpHeader[10] = byte(ssrc >> 8) + rtpHeader[11] = byte(ssrc & 0xFF) + + // Combine header with Opus payload + rtpPacket := make([]byte, len(rtpHeader)+len(opusData)) + copy(rtpPacket, rtpHeader) + copy(rtpPacket[len(rtpHeader):], opusData) + + return rtpPacket +} + +// handleSDPFile generates an SDP file for RTP/Opus streaming +func (h *StreamingHandlers) handleSDPFile(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := slog.With("device", deviceSerial) + logger.Info("📄 Serving SDP file for RTP/Opus stream", "device", deviceSerial) + + // Build the RTP stream URL + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + host := r.Host + if host == "" { + host = "localhost:29888" // fallback + } + + rtpURL := fmt.Sprintf("%s://%s/api/stream/audio/%s?codec=rtp", scheme, host, deviceSerial) + + // Generate SDP content + sdpContent := fmt.Sprintf(`v=0 +o=- 0 0 IN IP4 127.0.0.1 +s=Scrcpy Audio Stream +c=IN IP4 127.0.0.1 +t=0 0 +m=audio 0 RTP/AVP 96 +a=rtpmap:96 opus/48000/2 +a=fmtp:96 sprop-stereo=1 +a=tool:scrcpy-gbox +a=source-filter: incl IN IP4 127.0.0.1 %s +`, rtpURL) + + // Set content type and headers + w.Header().Set("Content-Type", "application/sdp") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s_audio.sdp\"", deviceSerial)) + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Write SDP content + if _, err := w.Write([]byte(sdpContent)); err != nil { + logger.Error("Failed to write SDP content", "error", err) + return + } + + logger.Info("✅ SDP file served successfully", "device", deviceSerial) +} + +// handleWebMOpusHTTP handles WebM-wrapped Opus audio streaming for FFmpeg compatibility +func (h *StreamingHandlers) handleWebMOpusHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := slog.With("device", deviceSerial) + logger.Info("🎵 Starting WebM/Opus audio HTTP stream", "url", r.URL.String()) + + // Set headers for WebM streaming + w.Header().Set("Content-Type", "audio/webm; codecs=opus") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Get audio stream from device source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + logger.Error("❌ Device source not found - is scrcpy running for this device?") + http.Error(w, "Device not connected", http.StatusServiceUnavailable) + return + } + + logger.Info("✅ Found scrcpy source for WebM/Opus streaming") + + // Subscribe to audio stream + subscriberID := fmt.Sprintf("audio_webm_%p", w) + audioCh := source.SubscribeAudio(subscriberID, 100) + defer source.UnsubscribeAudio(subscriberID) + + logger.Info("🎵 Subscribed to WebM/Opus stream", "subscriberID", subscriberID) + + // Write WebM header + webmHeader := h.createWebMHeader() + if _, err := w.Write(webmHeader); err != nil { + logger.Error("❌ Failed to write WebM header", "error", err) + return + } + + logger.Info("✅ WebM header sent", "size", len(webmHeader)) + + // Stream WebM-wrapped audio data to client + audioFrameCount := 0 + timestamp := uint64(0) + + logger.Info("🎵 Starting to stream WebM/Opus data to HTTP client") + + for { + select { + case <-r.Context().Done(): + logger.Info("🎵 WebM/Opus stream context cancelled") + return + + case audioSample, ok := <-audioCh: + if !ok { + logger.Info("🎵 WebM/Opus channel closed") + return + } + + audioFrameCount++ + + // Log first few frames to verify we're receiving audio data + if audioFrameCount <= 5 || audioFrameCount%50 == 0 { + logger.Info("🎵 Received audio sample for WebM", "frame", audioFrameCount, "size", len(audioSample.Data)) + } + + // Wrap Opus data in WebM SimpleBlock + if len(audioSample.Data) > 0 { + webmBlock := h.createWebMSimpleBlock(audioSample.Data, timestamp, audioFrameCount == 1) + + if _, err := w.Write(webmBlock); err != nil { + logger.Error("❌ Failed to write WebM data to HTTP response", "error", err, "frame", audioFrameCount) + return + } + + // Flush data immediately for low latency + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + // Update timestamp (20ms per frame = 960 samples at 48kHz) + timestamp += 20 // milliseconds + + // Log successful transmission for first few frames + if audioFrameCount <= 5 { + logger.Info("✅ Successfully sent WebM/Opus data to HTTP client", "frame", audioFrameCount, "size", len(webmBlock)) + } + } else { + logger.Warn("⚠️ Received empty audio sample for WebM", "frame", audioFrameCount) + } + } + } +} + +// createWebMHeader creates a minimal WebM header for Opus audio +func (h *StreamingHandlers) createWebMHeader() []byte { + // This is a simplified WebM header for audio-only Opus stream + // EBML Header + Segment + Info + Tracks + header := []byte{ + // EBML Header + 0x1A, 0x45, 0xDF, 0xA3, // EBML ID + 0x9F, // Size (unknown/live stream) + 0x42, 0x86, 0x81, 0x01, // EBMLVersion = 1 + 0x42, 0xF7, 0x81, 0x01, // EBMLReadVersion = 1 + 0x42, 0xF2, 0x81, 0x04, // EBMLMaxIDLength = 4 + 0x42, 0xF3, 0x81, 0x08, // EBMLMaxSizeLength = 8 + 0x42, 0x82, 0x88, 0x77, 0x65, 0x62, 0x6D, 0x00, 0x00, 0x00, 0x00, // DocType = "webm" + 0x42, 0x87, 0x81, 0x04, // DocTypeVersion = 4 + 0x42, 0x85, 0x81, 0x02, // DocTypeReadVersion = 2 + + // Segment + 0x18, 0x53, 0x80, 0x67, // Segment ID + 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // Size (unknown/live stream) + + // Info + 0x15, 0x49, 0xA9, 0x66, // Info ID + 0x8A, // Size = 10 + 0x2A, 0xD7, 0xB1, 0x83, 0x0F, 0x42, 0x40, // TimestampScale = 1000000 (1ms) + + // Tracks + 0x16, 0x54, 0xAE, 0x6B, // Tracks ID + 0x90, // Size = 16 + // TrackEntry + 0xAE, // TrackEntry ID + 0x8D, // Size = 13 + 0xD7, 0x81, 0x01, // TrackNumber = 1 + 0x73, 0xC5, 0x81, 0x01, // TrackUID = 1 + 0x83, 0x81, 0x02, // TrackType = 2 (audio) + 0x86, 0x85, 0x6F, 0x70, 0x75, 0x73, 0x00, // CodecID = "A_OPUS" + } + + return header +} + +// createWebMSimpleBlock creates a WebM SimpleBlock for Opus audio data +func (h *StreamingHandlers) createWebMSimpleBlock(opusData []byte, timestamp uint64, keyframe bool) []byte { + // SimpleBlock: Element ID + Size + Track Number + Timestamp + Flags + Data + + // Calculate size (track number + timestamp + flags + data) + dataSize := 1 + 2 + 1 + len(opusData) // track(1) + timestamp(2) + flags(1) + data + + block := make([]byte, 0, 4+8+dataSize) // ID(4) + size(up to 8) + data + + // SimpleBlock Element ID + block = append(block, 0xA3) + + // Size (variable length encoding) + if dataSize < 127 { + block = append(block, 0x80|byte(dataSize)) + } else { + block = append(block, 0x40, byte(dataSize>>8), byte(dataSize&0xFF)) + } + + // Track number (1 = audio track) + block = append(block, 0x81) // Track 1 + + // Timestamp (relative to cluster, 16-bit signed) + block = append(block, byte(timestamp>>8), byte(timestamp&0xFF)) + + // Flags (keyframe flag) + flags := byte(0x00) + if keyframe { + flags |= 0x80 // Keyframe flag + } + block = append(block, flags) + + // Opus data + block = append(block, opusData...) + + return block +} + +// handleOpusStreamHTTP handles properly formatted Opus audio streaming +func (h *StreamingHandlers) handleOpusStreamHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := slog.With("device", deviceSerial) + logger.Info("🎵 Starting Opus stream HTTP", "url", r.URL.String()) + + // Set headers for Opus streaming + w.Header().Set("Content-Type", "audio/opus") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Get audio stream from device source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + logger.Error("❌ Device source not found - is scrcpy running for this device?") + http.Error(w, "Device not connected", http.StatusServiceUnavailable) + return + } + + logger.Info("✅ Found scrcpy source for Opus streaming") + + // Subscribe to audio stream + subscriberID := fmt.Sprintf("audio_opus_stream_%p", w) + audioCh := source.SubscribeAudio(subscriberID, 100) + defer source.UnsubscribeAudio(subscriberID) + + logger.Info("🎵 Subscribed to Opus stream", "subscriberID", subscriberID) + + // Process audio stream + audioFrameCount := 0 + + logger.Info("🎵 Starting to stream Opus data to HTTP client") + + for { + select { + case <-r.Context().Done(): + logger.Info("🎵 Opus stream context cancelled") + return + + case audioSample, ok := <-audioCh: + if !ok { + logger.Info("🎵 Opus channel closed") + return + } + + audioFrameCount++ + + // Log first few frames + if audioFrameCount <= 5 || audioFrameCount%50 == 0 { + logger.Info("🎵 Received audio sample for Opus stream", "frame", audioFrameCount, "size", len(audioSample.Data)) + } + + if len(audioSample.Data) > 0 { + // Handle all packets like WebRTC does - no filtering, just send them + // Log detailed info for first few packets to understand the stream + if audioFrameCount <= 10 { + isOpusHead := h.isOpusConfigPacket(audioSample.Data) + logger.Info("🎵 Audio packet details", "frame", audioFrameCount, "size", len(audioSample.Data), "isOpusHead", isOpusHead) + + // Show hex dump for very first packet + if audioFrameCount == 1 { + hexData := "" + dataLen := min(len(audioSample.Data), 32) + for i, b := range audioSample.Data[:dataLen] { + if i > 0 { + hexData += " " + } + hexData += fmt.Sprintf("%02x", b) + } + logger.Info("🔍 First packet hex", "hex", hexData) + } + } + + // Send all audio data just like WebRTC does + if _, err := w.Write(audioSample.Data); err != nil { + logger.Error("❌ Failed to write Opus data to HTTP response", "error", err, "frame", audioFrameCount) + return + } + + // Flush data immediately for low latency + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + // Log successful transmission for first few frames + if audioFrameCount <= 5 { + logger.Info("✅ Successfully sent Opus data to HTTP client", "frame", audioFrameCount, "size", len(audioSample.Data)) + } + } else { + logger.Warn("⚠️ Received empty audio sample for Opus stream", "frame", audioFrameCount) + } + } + } +} + +// isOpusConfigPacket checks if the data contains an Opus configuration packet +func (h *StreamingHandlers) isOpusConfigPacket(data []byte) bool { + // Check for OpusHead signature + opusHead := []byte("OpusHead") + if len(data) >= len(opusHead) { + for i, b := range opusHead { + if data[i] != b { + return false + } + } + return true + } + return false +} + +// handleTestAudioHTTP handles test audio streaming that mimics WebRTC exactly +func (h *StreamingHandlers) handleTestAudioHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := slog.With("device", deviceSerial) + logger.Info("🧪 Starting test audio HTTP stream", "url", r.URL.String()) + + // Set headers + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Get audio stream from device source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + logger.Error("❌ Device source not found") + http.Error(w, "Device not connected", http.StatusServiceUnavailable) + return + } + + // Subscribe to audio stream - exactly like WebRTC does + audioCh := source.SubscribeAudio("test_audio_stream", 100) + defer source.UnsubscribeAudio("test_audio_stream") + + sampleCount := 0 + logger.Info("🧪 Test audio streaming started") + + for { + select { + case <-r.Context().Done(): + logger.Info("🧪 Test audio stream cancelled") + return + case sample, ok := <-audioCh: + if !ok { + logger.Info("🧪 Test audio channel closed") + return + } + + // Skip empty samples just like WebRTC does + if sample.Data == nil || len(sample.Data) == 0 { + continue + } + + sampleCount++ + + // Log first few samples with detailed analysis + if sampleCount <= 10 { + logger.Info("🧪 Test audio sample", "count", sampleCount, "size", len(sample.Data), "pts", sample.PTS) + + // Show hex dump for first few samples + if sampleCount <= 3 && len(sample.Data) > 0 { + hexData := "" + dataLen := min(len(sample.Data), 16) + for i, b := range sample.Data[:dataLen] { + if i > 0 { + hexData += " " + } + hexData += fmt.Sprintf("%02x", b) + } + logger.Info("🧪 Sample hex", "count", sampleCount, "hex", hexData) + + // Analyze Opus frame structure + if len(sample.Data) > 0 { + toc := sample.Data[0] + config := (toc >> 3) & 0x1F + stereo := (toc >> 2) & 0x1 + frameCount := toc & 0x3 + logger.Info("🧪 Opus analysis", "count", sampleCount, "toc", fmt.Sprintf("0x%02x", toc), "config", config, "stereo", stereo, "frames", frameCount) + } + } + } + + // Write raw data exactly like WebRTC does + if _, err := w.Write(sample.Data); err != nil { + logger.Error("❌ Failed to write test audio data", "error", err) + return + } + + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + } +} diff --git a/packages/cli/internal/server/handlers/utils.go b/packages/cli/internal/server/handlers/utils.go new file mode 100644 index 00000000..258be59f --- /dev/null +++ b/packages/cli/internal/server/handlers/utils.go @@ -0,0 +1,13 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +// RespondJSON sends a JSON response with the given status code and data +func RespondJSON(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(data) +} \ No newline at end of file diff --git a/packages/cli/internal/server/handlers/webrtc.go b/packages/cli/internal/server/handlers/webrtc.go new file mode 100644 index 00000000..ab35551a --- /dev/null +++ b/packages/cli/internal/server/handlers/webrtc.go @@ -0,0 +1,330 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/webrtc" + "github.com/gorilla/websocket" + pionwebrtc "github.com/pion/webrtc/v4" +) + +// WebRTCHandlers handles WebRTC signaling operations +type WebRTCHandlers struct { + serverService ServerService + upgrader websocket.Upgrader + webrtcManager *webrtc.Manager +} + +// NewWebRTCHandlers creates a new WebRTC handlers instance +func NewWebRTCHandlers(serverSvc ServerService) *WebRTCHandlers { + return &WebRTCHandlers{ + serverService: serverSvc, + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins for development + }, + }, + webrtcManager: webrtc.NewManager("adb"), // Use default adb path + } +} + +// HandleWebRTCSignaling handles WebRTC signaling WebSocket connections +func (h *WebRTCHandlers) HandleWebRTCSignaling(conn *websocket.Conn, deviceSerial string) { + log.Printf("WebRTC signaling connection established for device: %s", deviceSerial) + + // Check and clean up any existing connections for this device + if existingBridge, exists := h.webrtcManager.GetBridge(deviceSerial); exists { + if pc := existingBridge.GetPeerConnection(); pc != nil { + log.Printf("Existing bridge found for device: %s, state: %s", deviceSerial, pc.ConnectionState().String()) + } + } + + for { + var msg map[string]interface{} + if err := conn.ReadJSON(&msg); err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("WebRTC signaling read error: %v", err) + } + break + } + + msgType, ok := msg["type"].(string) + if !ok { + continue + } + + log.Printf("WebRTC signaling message received: type=%s, device=%s", msgType, deviceSerial) + + switch msgType { + case "offer": + h.handleOffer(conn, msg, deviceSerial) + + case "answer": + h.handleAnswer(conn, msg, deviceSerial) + + case "ice-candidate": + h.handleIceCandidate(conn, msg, deviceSerial) + + case "ping": + h.handlePing(conn, msg) + + default: + log.Printf("Unknown WebRTC signaling message type: %s", msgType) + } + } +} + +// handleOffer processes WebRTC offer messages +func (h *WebRTCHandlers) handleOffer(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { + log.Printf("WebRTC offer received: device=%s", deviceSerial) + + // Extract the offer SDP from the message + var offerSDP string + if offer, exists := msg["offer"].(map[string]interface{}); exists { + if sdp, ok := offer["sdp"].(string); ok { + offerSDP = sdp + } + } else if sdp, ok := msg["sdp"].(string); ok { + offerSDP = sdp + } + + if offerSDP == "" { + log.Printf("No valid SDP found in offer message") + h.sendError(conn, "Invalid offer: missing SDP") + return + } + + // Check if existing bridge's peer connection is closed, if so remove it + if existingBridge, exists := h.webrtcManager.GetBridge(deviceSerial); exists { + if pc := existingBridge.GetPeerConnection(); pc != nil { + if pc.ConnectionState() == pionwebrtc.PeerConnectionStateClosed || + pc.ConnectionState() == pionwebrtc.PeerConnectionStateFailed { + log.Printf("Removing closed WebRTC bridge for device: %s", deviceSerial) + h.webrtcManager.RemoveBridge(deviceSerial) + // Add delay to ensure complete ICE cleanup + time.Sleep(1000 * time.Millisecond) + } + } + } + + // Create or get WebRTC bridge for this device + bridge, err := h.webrtcManager.CreateBridge(deviceSerial) + if err != nil { + log.Printf("Failed to create WebRTC bridge: %v", err) + h.sendError(conn, fmt.Sprintf("Failed to create bridge: %v", err)) + return + } + + // Get the peer connection from the bridge + pc := bridge.GetPeerConnection() + if pc == nil { + log.Printf("No peer connection available from bridge") + h.sendError(conn, "Peer connection not available") + return + } + + // Check peer connection state before setting remote description + if pc.ConnectionState() == pionwebrtc.PeerConnectionStateClosed || + pc.ConnectionState() == pionwebrtc.PeerConnectionStateFailed { + log.Printf("Peer connection is closed/failed, recreating bridge for device: %s", deviceSerial) + h.webrtcManager.RemoveBridge(deviceSerial) + + // Create new bridge + bridge, err = h.webrtcManager.CreateBridge(deviceSerial) + if err != nil { + log.Printf("Failed to recreate WebRTC bridge: %v", err) + h.sendError(conn, fmt.Sprintf("Failed to recreate bridge: %v", err)) + return + } + pc = bridge.GetPeerConnection() + if pc == nil { + log.Printf("No peer connection available from new bridge") + h.sendError(conn, "Peer connection not available") + return + } + } + + // Set the remote offer + offerDesc := pionwebrtc.SessionDescription{ + Type: pionwebrtc.SDPTypeOffer, + SDP: offerSDP, + } + + log.Printf("Setting remote description for device: %s, PC state: %s", deviceSerial, pc.ConnectionState().String()) + // Debug: Offer SDP preview (disabled in production) + // log.Printf("Offer SDP preview: %.200s...", offerSDP) + if err := pc.SetRemoteDescription(offerDesc); err != nil { + log.Printf("Failed to set remote description: %v", err) + h.sendError(conn, fmt.Sprintf("Failed to set remote description: %v", err)) + return + } + + // Create answer + answer, err := pc.CreateAnswer(nil) + if err != nil { + log.Printf("Failed to create answer: %v", err) + h.sendError(conn, fmt.Sprintf("Failed to create answer: %v", err)) + return + } + + // Set local description + if err := pc.SetLocalDescription(answer); err != nil { + log.Printf("Failed to set local description: %v", err) + h.sendError(conn, fmt.Sprintf("Failed to set local description: %v", err)) + return + } + + // Debug: Answer SDP preview (disabled in production) + // log.Printf("Answer SDP preview: %.200s...", answer.SDP) + + // Send answer back to client (using old format) + answerResponse := map[string]interface{}{ + "type": "answer", + "answer": map[string]interface{}{ + "type": "answer", + "sdp": answer.SDP, + }, + } + + if err := conn.WriteJSON(answerResponse); err != nil { + log.Printf("Failed to send WebRTC answer: %v", err) + return + } + + log.Printf("WebRTC answer sent successfully for device: %s, PC state: %s", deviceSerial, pc.ConnectionState().String()) + + // Set up ICE candidate forwarding AFTER sending answer (like old implementation) + pc.OnICECandidate(func(candidate *pionwebrtc.ICECandidate) { + if candidate == nil { + log.Printf("ICE candidate gathering finished for device: %s", deviceSerial) + return + } + + // Debug: ICE candidate forwarding (disabled in production) + // log.Printf("Forwarding server ICE candidate to client for device: %s", deviceSerial) + + // Use ToJSON() like old implementation + candidateJSON := candidate.ToJSON() + candidateMessage := map[string]interface{}{ + "type": "ice-candidate", + "candidate": map[string]interface{}{ + "candidate": candidateJSON.Candidate, + "sdpMLineIndex": candidateJSON.SDPMLineIndex, + "sdpMid": candidateJSON.SDPMid, + }, + } + + if err := conn.WriteJSON(candidateMessage); err != nil { + log.Printf("Failed to send ICE candidate to client: %v", err) + } + }) + + // Add timeout monitoring for connection establishment + go func() { + time.Sleep(10 * time.Second) + if pc.ConnectionState() != pionwebrtc.PeerConnectionStateConnected { + log.Printf("WebRTC connection timeout for device: %s, current state: %s", deviceSerial, pc.ConnectionState().String()) + } + }() +} + +// handleAnswer processes WebRTC answer messages +func (h *WebRTCHandlers) handleAnswer(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { + log.Printf("WebRTC answer received: device=%s", deviceSerial) + + // TODO: Process WebRTC answer from client + // This would typically be sent to the media server for processing + log.Printf("WebRTC answer processing not yet implemented") +} + +// handleIceCandidate processes WebRTC ICE candidate messages +func (h *WebRTCHandlers) handleIceCandidate(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { + log.Printf("WebRTC ICE candidate received: device=%s", deviceSerial) + + // Get WebRTC bridge for this device + bridge, exists := h.webrtcManager.GetBridge(deviceSerial) + if !exists { + log.Printf("No WebRTC bridge found for device: %s", deviceSerial) + h.sendError(conn, "No bridge found for device") + return + } + + // Get the peer connection + pc := bridge.GetPeerConnection() + if pc == nil { + log.Printf("No peer connection available from bridge") + h.sendError(conn, "Peer connection not available") + return + } + + // Extract ICE candidate from message + candidateData, ok := msg["candidate"].(map[string]interface{}) + if !ok { + log.Printf("Invalid ICE candidate format") + h.sendError(conn, "Invalid ICE candidate format") + return + } + + candidateStr, ok := candidateData["candidate"].(string) + if !ok { + log.Printf("Missing candidate string") + h.sendError(conn, "Missing candidate string") + return + } + + sdpMid, ok := candidateData["sdpMid"].(string) + if !ok { + log.Printf("Missing sdpMid") + h.sendError(conn, "Missing sdpMid") + return + } + + sdpMLineIndex, ok := candidateData["sdpMLineIndex"].(float64) + if !ok { + log.Printf("Missing sdpMLineIndex") + h.sendError(conn, "Missing sdpMLineIndex") + return + } + + // Create ICE candidate + candidate := pionwebrtc.ICECandidateInit{ + Candidate: candidateStr, + SDPMid: &sdpMid, + SDPMLineIndex: (*uint16)(&[]uint16{uint16(sdpMLineIndex)}[0]), + } + + // Add ICE candidate to peer connection + log.Printf("Adding ICE candidate for device: %s, candidate: %.50s...", deviceSerial, candidateStr) + if err := pc.AddICECandidate(candidate); err != nil { + log.Printf("Failed to add ICE candidate: %v", err) + h.sendError(conn, fmt.Sprintf("Failed to add ICE candidate: %v", err)) + return + } + + // Debug: ICE candidate added (disabled in production) + // log.Printf("ICE candidate added successfully for device: %s", deviceSerial) +} + +// handlePing handles ping messages for latency measurement +func (h *WebRTCHandlers) handlePing(conn *websocket.Conn, msg map[string]interface{}) { + pongMsg := map[string]interface{}{ + "type": "pong", + } + // Pass through the ping ID if present for latency calculation + if id, exists := msg["id"]; exists { + pongMsg["id"] = id + } + conn.WriteJSON(pongMsg) +} + +// sendError sends an error message to the client +func (h *WebRTCHandlers) sendError(conn *websocket.Conn, errorMsg string) { + conn.WriteJSON(map[string]interface{}{ + "type": "error", + "error": errorMsg, + }) +} + diff --git a/packages/cli/internal/server/router/adb.go b/packages/cli/internal/server/router/adb.go new file mode 100644 index 00000000..44ae72fb --- /dev/null +++ b/packages/cli/internal/server/router/adb.go @@ -0,0 +1,28 @@ +package router + +import ( + "net/http" + "github.com/babelcloud/gbox/packages/cli/internal/server/handlers" +) + +// ADBRouter handles all ADB expose routes +type ADBRouter struct { + handlers *handlers.ADBExposeHandlers +} + +// RegisterRoutes registers all ADB expose routes +func (r *ADBRouter) RegisterRoutes(mux *http.ServeMux, server interface{}) { + // Create handlers instance + r.handlers = handlers.NewADBExposeHandlers() + + // ADB expose endpoints + mux.HandleFunc("/api/adb/expose/start", r.handlers.HandleADBExposeStart) + mux.HandleFunc("/api/adb/expose/stop", r.handlers.HandleADBExposeStop) + mux.HandleFunc("/api/adb/expose/status", r.handlers.HandleADBExposeStatus) + mux.HandleFunc("/api/adb/expose/list", r.handlers.HandleADBExposeList) +} + +// GetPathPrefix returns the path prefix for this router +func (r *ADBRouter) GetPathPrefix() string { + return "/api/adb" +} \ No newline at end of file diff --git a/packages/cli/internal/server/router/api.go b/packages/cli/internal/server/router/api.go new file mode 100644 index 00000000..35593bc1 --- /dev/null +++ b/packages/cli/internal/server/router/api.go @@ -0,0 +1,57 @@ +package router + +import ( + "net/http" + "github.com/babelcloud/gbox/packages/cli/internal/server/handlers" +) + +// APIRouter handles all /api/* routes +type APIRouter struct{ + handlers *handlers.APIHandlers +} + +// RegisterRoutes registers all API routes +func (r *APIRouter) RegisterRoutes(mux *http.ServeMux, server interface{}) { + // Cast server to ServerService + var serverService handlers.ServerService + if srv, ok := server.(handlers.ServerService); ok { + serverService = srv + } + + // Create handlers instance with actual server service + r.handlers = handlers.NewAPIHandlers(serverService) + + // Create box handlers separately + boxHandlers := handlers.NewBoxHandlers(serverService) + + // Health and status endpoints + mux.HandleFunc("/api/health", r.handlers.HandleHealth) + mux.HandleFunc("/api/status", r.handlers.HandleStatus) + + // Device management endpoints + mux.HandleFunc("/api/devices", r.handlers.HandleDeviceList) + mux.HandleFunc("/api/devices/", r.handlers.HandleDeviceAction) + mux.HandleFunc("/api/devices/register", r.handlers.HandleDeviceRegister) + mux.HandleFunc("/api/devices/unregister", r.handlers.HandleDeviceUnregister) + + // Box management endpoints (proxy to remote GBOX API) + mux.HandleFunc("/api/boxes", boxHandlers.HandleBoxList) + + // Note: Streaming endpoints are handled by StreamingRouter + + // ADB Expose endpoints + mux.HandleFunc("/api/adb-expose/start", r.handlers.HandleADBExposeStart) + mux.HandleFunc("/api/adb-expose/stop", r.handlers.HandleADBExposeStop) + mux.HandleFunc("/api/adb-expose/status", r.handlers.HandleADBExposeStatus) + mux.HandleFunc("/api/adb-expose/list", r.handlers.HandleADBExposeList) + + // Server management endpoints + mux.HandleFunc("/api/server/shutdown", r.handlers.HandleServerShutdown) + mux.HandleFunc("/api/server/info", r.handlers.HandleServerInfo) +} + +// GetPathPrefix returns the path prefix for this router +func (r *APIRouter) GetPathPrefix() string { + return "/api" +} + diff --git a/packages/cli/internal/server/router/assets.go b/packages/cli/internal/server/router/assets.go new file mode 100644 index 00000000..f744da8a --- /dev/null +++ b/packages/cli/internal/server/router/assets.go @@ -0,0 +1,33 @@ +package router + +import ( + "io/fs" + "net/http" + + "github.com/babelcloud/gbox/packages/cli/internal/server/handlers" +) + +// AssetsRouter handles all /assets/* routes +type AssetsRouter struct { + handlers *handlers.AssetsHandlers +} + +// RegisterRoutes registers all static asset routes +func (r *AssetsRouter) RegisterRoutes(mux *http.ServeMux, server interface{}) { + // Get static filesystem from server + var staticFS fs.FS + if serverService, ok := server.(handlers.ServerService); ok { + staticFS = serverService.GetStaticFS() + } + + // Create handlers instance + r.handlers = handlers.NewAssetsHandlers(staticFS) + + // Main assets handler + mux.HandleFunc("/assets/", r.handlers.HandleAssets) +} + +// GetPathPrefix returns the path prefix for this router +func (r *AssetsRouter) GetPathPrefix() string { + return "/assets" +} diff --git a/packages/cli/internal/server/router/pages.go b/packages/cli/internal/server/router/pages.go new file mode 100644 index 00000000..0ebd5d14 --- /dev/null +++ b/packages/cli/internal/server/router/pages.go @@ -0,0 +1,37 @@ +package router + +import ( + "io/fs" + "net/http" + "github.com/babelcloud/gbox/packages/cli/internal/server/handlers" +) + +// PagesRouter handles page routes (/, /live-view, /adb-expose) +type PagesRouter struct{ + handlers *handlers.PagesHandlers +} + +// RegisterRoutes registers all page routes +func (r *PagesRouter) RegisterRoutes(mux *http.ServeMux, server interface{}) { + // Create handlers instance with static filesystem from server + var staticFS fs.FS = nil + if serverService, ok := server.(handlers.ServerService); ok { + staticFS = serverService.GetStaticFS() + } + r.handlers = handlers.NewPagesHandlers(staticFS) + + // Main pages + mux.HandleFunc("/live-view", r.handlers.HandleLiveView) + mux.HandleFunc("/live-view/", r.handlers.HandleLiveView) + mux.HandleFunc("/adb-expose", r.handlers.HandleADBExpose) + mux.HandleFunc("/adb-expose/", r.handlers.HandleADBExpose) + + // Root handler (must be registered last) + mux.HandleFunc("/", r.handlers.HandleRoot) +} + + +// GetPathPrefix returns the path prefix for this router +func (r *PagesRouter) GetPathPrefix() string { + return "/" +} \ No newline at end of file diff --git a/packages/cli/internal/server/router/path_utils.go b/packages/cli/internal/server/router/path_utils.go new file mode 100644 index 00000000..cbea12e2 --- /dev/null +++ b/packages/cli/internal/server/router/path_utils.go @@ -0,0 +1,54 @@ +package router + +import ( + "net/http" + "strings" +) + +// PathTransformer handles automatic path prefix transformation +type PathTransformer struct { + prefix string +} + +// NewPathTransformer creates a new path transformer with the given prefix +func NewPathTransformer(prefix string) *PathTransformer { + return &PathTransformer{ + prefix: strings.TrimSuffix(prefix, "/"), + } +} + +// TransformHandler wraps a handler to automatically transform request paths +// It removes the prefix from the request path before passing to the handler +func (pt *PathTransformer) TransformHandler(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Create a copy of the request with the transformed path + newReq := *r + newURL := *r.URL + newReq.URL = &newURL + + // Remove the prefix from the path + if strings.HasPrefix(r.URL.Path, pt.prefix) { + newReq.URL.Path = strings.TrimPrefix(r.URL.Path, pt.prefix) + // Ensure we don't have double slashes + if !strings.HasPrefix(newReq.URL.Path, "/") { + newReq.URL.Path = "/" + newReq.URL.Path + } + } + + // Call the original handler with the transformed request + handler(w, &newReq) + } +} + +// AddPrefix adds the configured prefix to a path +func (pt *PathTransformer) AddPrefix(path string) string { + if pt.prefix == "" { + return path + } + + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + return pt.prefix + path +} \ No newline at end of file diff --git a/packages/cli/internal/server/router/router.go b/packages/cli/internal/server/router/router.go new file mode 100644 index 00000000..dc6191c9 --- /dev/null +++ b/packages/cli/internal/server/router/router.go @@ -0,0 +1,39 @@ +package router + +import ( + "net/http" +) + +// Router defines the interface for route registration +type Router interface { + RegisterRoutes(mux *http.ServeMux, server interface{}) + GetPathPrefix() string +} + +// RouteGroup helps organize related routes +type RouteGroup struct { + prefix string + mux *http.ServeMux + server interface{} +} + +// NewRouteGroup creates a new route group with a common prefix +func NewRouteGroup(prefix string, mux *http.ServeMux, server interface{}) *RouteGroup { + return &RouteGroup{ + prefix: prefix, + mux: mux, + server: server, + } +} + +// HandleFunc registers a handler function with the group's prefix +func (g *RouteGroup) HandleFunc(pattern string, handler http.HandlerFunc) { + fullPattern := g.prefix + pattern + g.mux.HandleFunc(fullPattern, handler) +} + +// Handle registers a handler with the group's prefix +func (g *RouteGroup) Handle(pattern string, handler http.Handler) { + fullPattern := g.prefix + pattern + g.mux.Handle(fullPattern, handler) +} \ No newline at end of file diff --git a/packages/cli/internal/server/router/streaming.go b/packages/cli/internal/server/router/streaming.go new file mode 100644 index 00000000..8a9c913d --- /dev/null +++ b/packages/cli/internal/server/router/streaming.go @@ -0,0 +1,52 @@ +package router + +import ( + "net/http" + "github.com/babelcloud/gbox/packages/cli/internal/server/handlers" +) + +// StreamingRouter handles all streaming routes +type StreamingRouter struct { + handlers *handlers.StreamingHandlers + transformer *PathTransformer +} + +// RegisterRoutes registers all streaming routes +func (r *StreamingRouter) RegisterRoutes(mux *http.ServeMux, server interface{}) { + // Create handlers instance + r.handlers = handlers.NewStreamingHandlers() + + // Create path transformer with /api prefix + r.transformer = NewPathTransformer("/api") + + // Set server service dependency if server implements ServerService + if serverService, ok := server.(handlers.ServerService); ok { + r.handlers.SetServerService(serverService) + } + + // Set the path prefix for URL responses + r.handlers.SetPathPrefix("/api") + + // Video streaming endpoints (handlers expect /stream/video/ but we serve from /api/stream/video/) + mux.HandleFunc("/api/stream/video/", r.transformer.TransformHandler(r.handlers.HandleVideoStream)) + + // Audio streaming endpoints + mux.HandleFunc("/api/stream/audio/", r.transformer.TransformHandler(r.handlers.HandleAudioStream)) + + // WebSocket video streaming (consolidated from /ws/video/ and /stream/ws/) + mux.HandleFunc("/api/stream/video/ws/", r.transformer.TransformHandler(r.handlers.HandleVideoWebSocket)) + + // Device control WebSocket + mux.HandleFunc("/api/stream/control/", r.transformer.TransformHandler(r.handlers.HandleControlWebSocket)) + + // Stream connection management endpoints + mux.HandleFunc("/api/stream/", r.transformer.TransformHandler(r.handlers.HandleStreamConnect)) + + // Stream info endpoint + mux.HandleFunc("/api/stream/info", r.transformer.TransformHandler(r.handlers.HandleStreamInfo)) +} + +// GetPathPrefix returns the path prefix for this router +func (r *StreamingRouter) GetPathPrefix() string { + return "/stream" +} \ No newline at end of file diff --git a/packages/cli/internal/server/server.go b/packages/cli/internal/server/server.go index b8dbf027..0c293a88 100644 --- a/packages/cli/internal/server/server.go +++ b/packages/cli/internal/server/server.go @@ -15,60 +15,16 @@ import ( "sync" "time" - adb_expose "github.com/babelcloud/gbox/packages/cli/internal/adb_expose" client "github.com/babelcloud/gbox/packages/cli/internal/client" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/webrtc" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/webrtc" + "github.com/babelcloud/gbox/packages/cli/internal/server/handlers" + "github.com/babelcloud/gbox/packages/cli/internal/server/router" ) //go:embed all:static var staticFiles embed.FS -// PortManager manages all port forwarding instances -type PortManager struct { - forwards map[string]*PortForward // key: boxID - mu sync.RWMutex -} - -// PortForward represents a port forwarding instance -type PortForward struct { - BoxID string `json:"boxid"` - LocalPorts []int `json:"localports"` - RemotePorts []int `json:"remoteports"` - StartedAt time.Time `json:"started_at"` - Status string `json:"status"` // "running", "stopped", "error" - Error string `json:"error,omitempty"` - client *adb_expose.MultiplexClient - mu sync.RWMutex -} - -// ConnectionPool manages WebSocket connections to remote servers -type ConnectionPool struct { - connections map[string]*adb_expose.MultiplexClient // key: boxID - mu sync.RWMutex -} - -// StartRequest represents a request to start port forwarding -type StartRequest struct { - BoxID string `json:"boxid"` - LocalPorts []int `json:"localports"` - RemotePorts []int `json:"remoteports"` - Config adb_expose.Config `json:"config"` -} - -// Stop stops the port forward -func (pf *PortForward) Stop() { - pf.mu.Lock() - defer pf.mu.Unlock() - - if pf.Status == "stopped" { - return - } - pf.Status = "stopped" - if pf.client != nil { - pf.client.Close() - } -} // GBoxServer is the unified server for all gbox services type GBoxServer struct { @@ -77,12 +33,7 @@ type GBoxServer struct { mux *http.ServeMux // Services - webrtcManager *webrtc.Manager - adbExpose *ADBExposeService - - // ADB Expose functionality integrated directly - portManager *PortManager - connectionPool *ConnectionPool + bridgeManager *webrtc.Manager // State mu sync.RWMutex @@ -98,14 +49,11 @@ func NewGBoxServer(port int) *GBoxServer { ctx, cancel := context.WithCancel(context.Background()) return &GBoxServer{ - port: port, - mux: http.NewServeMux(), - webrtcManager: webrtc.NewManager("adb"), - adbExpose: NewADBExposeService(), - portManager: &PortManager{forwards: make(map[string]*PortForward)}, - connectionPool: &ConnectionPool{connections: make(map[string]*adb_expose.MultiplexClient)}, - ctx: ctx, - cancel: cancel, + port: port, + mux: http.NewServeMux(), + bridgeManager: webrtc.NewManager("adb"), + ctx: ctx, + cancel: cancel, } } @@ -156,33 +104,21 @@ func (s *GBoxServer) Stop() error { s.cancel() - // Shutdown HTTP server + // Shutdown HTTP server with longer timeout if s.httpServer != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := s.httpServer.Shutdown(ctx); err != nil { log.Printf("HTTP server shutdown error: %v", err) + // Force close if graceful shutdown fails + if err := s.httpServer.Close(); err != nil { + log.Printf("HTTP server force close error: %v", err) + } } } // Cleanup services - s.webrtcManager.Close() - s.adbExpose.Close() - - // Cleanup ADB expose functionality - s.portManager.mu.Lock() - for _, forward := range s.portManager.forwards { - forward.Stop() - } - s.portManager.forwards = make(map[string]*PortForward) - s.portManager.mu.Unlock() - - s.connectionPool.mu.Lock() - for _, client := range s.connectionPool.connections { - client.Close() - } - s.connectionPool.connections = make(map[string]*adb_expose.MultiplexClient) - s.connectionPool.mu.Unlock() + s.bridgeManager.Close() s.running = false log.Println("GBox server stopped") @@ -196,56 +132,31 @@ func (s *GBoxServer) IsRunning() bool { return s.running } -// setupRoutes sets up all HTTP routes +// setupRoutes sets up all HTTP routes using the new router system func (s *GBoxServer) setupRoutes() { - // Health check and status - s.mux.HandleFunc("/health", s.handleStatus) - s.mux.HandleFunc("/api/status", s.handleStatus) - - // Device Connect API (scrcpy/WebRTC) - s.mux.HandleFunc("/api/devices", s.handleDevices) - s.mux.HandleFunc("/api/devices/", s.handleDeviceAction) // Handles /api/devices/{id}/connect and /api/devices/{id}/disconnect - s.mux.HandleFunc("/api/devices/register", s.handleRegisterDevice) - s.mux.HandleFunc("/api/devices/unregister", s.handleUnregisterDevice) - s.mux.HandleFunc("/ws", s.handleWebSocket) - - // Box API - s.mux.HandleFunc("/api/boxes", s.handleBoxList) - - // ADB Expose API - s.mux.HandleFunc("/api/adb-expose/start", s.handleADBExposeStart) - s.mux.HandleFunc("/api/adb-expose/stop", s.handleADBExposeStop) - s.mux.HandleFunc("/api/adb-expose/status", s.handleADBExposeStatus) - s.mux.HandleFunc("/api/adb-expose/list", s.handleADBExposeList) - - // Server management API - s.mux.HandleFunc("/api/server/shutdown", s.handleShutdown) - s.mux.HandleFunc("/api/server/info", s.handleServerInfo) - - // Live-view assets - serve from live-view static directory - s.mux.HandleFunc("/assets/", s.handleLiveViewAssets) - - // Sub-applications - handle both with and without trailing slash - s.mux.HandleFunc("/live-view", s.handleLiveView) - s.mux.HandleFunc("/live-view/", s.handleLiveView) - s.mux.HandleFunc("/live-view.html", s.handleLiveViewHTML) - s.mux.HandleFunc("/adb-expose", s.handleAdbExposeUI) - s.mux.HandleFunc("/adb-expose/", s.handleAdbExposeUI) - - // Static files and web UI routes - must be last + // Register routers in order of specificity (most specific first) + routers := []router.Router{ + &router.APIRouter{}, + &router.StreamingRouter{}, + &router.ADBRouter{}, + &router.AssetsRouter{}, + &router.PagesRouter{}, // Must be last as it includes root handler + } + + // Register all routes + for _, r := range routers { + r.RegisterRoutes(s.mux, s) + } + + // Setup static files as fallback (must be last) s.setupStaticFiles() } // setupStaticFiles sets up static file serving +// Note: Root path is now handled by PagesRouter func (s *GBoxServer) setupStaticFiles() { - // Try embedded server static files - staticFS, err := fs.Sub(staticFiles, "static") - if err == nil { - s.mux.Handle("/", http.FileServer(http.FS(staticFS))) - } else { - // Fallback to a simple status page - s.mux.HandleFunc("/", s.handleRoot) - } + // Root path is handled by PagesRouter, no additional setup needed + // This function is kept for potential future static file setup } // serveStaticWithMIME wraps a file server to set correct MIME types @@ -263,38 +174,6 @@ func (s *GBoxServer) serveStaticWithMIME(fs http.FileSystem) http.Handler { }) } -// findLiveViewStaticPath finds the live-view build output -func (s *GBoxServer) findLiveViewStaticPath() string { - // Note: embedded files removed - using external files only - - // Fallback to external files for development - possiblePaths := []string{ - // Relative to gbox binary location - "../../live-view/static", - "../live-view/static", - "packages/live-view/static", - // In user's home directory - filepath.Join(os.Getenv("HOME"), ".gbox", "live-view-static"), - // Development paths - "./packages/live-view/static", - "../../../gbox/packages/live-view/static", - } - - for _, path := range possiblePaths { - absPath, err := filepath.Abs(path) - if err != nil { - continue - } - if info, err := os.Stat(absPath); err == nil && info.IsDir() { - if _, err := os.Stat(filepath.Join(absPath, "index.html")); err == nil { - return absPath - } - } - } - - log.Printf("Warning: Live-view static files not found, using default status page") - return "" -} // findStaticPath finds the server static files directory func (s *GBoxServer) findStaticPath() string { @@ -358,7 +237,7 @@ func (s *GBoxServer) handleStatus(w http.ResponseWriter, r *http.Request) { "uptime": uptime.String(), "services": map[string]interface{}{ "device_connect": true, - "adb_expose": s.adbExpose.IsRunning(), + "adb_expose": true, // Always available through handlers }, "version": BuildInfo.Version, "build_id": GetBuildID(), @@ -507,6 +386,95 @@ func (s *GBoxServer) handleBoxList(w http.ResponseWriter, r *http.Request) { }) } +// ServerService interface implementations for handlers + +// GetPort returns the server port +func (s *GBoxServer) GetPort() int { + return s.port +} + +// GetUptime returns server uptime +func (s *GBoxServer) GetUptime() time.Duration { + s.mu.RLock() + defer s.mu.RUnlock() + return time.Since(s.startTime) +} + +// GetBuildID returns build ID +func (s *GBoxServer) GetBuildID() string { + return s.buildID +} + +// GetVersion returns version info +func (s *GBoxServer) GetVersion() string { + return BuildInfo.Version +} + +// IsADBExposeRunning returns ADB expose status +func (s *GBoxServer) IsADBExposeRunning() bool { + return true // Always available through handlers +} + +// ListBridges returns list of bridge device serials +func (s *GBoxServer) ListBridges() []string { + return s.bridgeManager.ListBridges() +} + +// CreateBridge creates a bridge for device +func (s *GBoxServer) CreateBridge(deviceSerial string) error { + _, err := s.bridgeManager.CreateBridge(deviceSerial) + return err +} + +// RemoveBridge removes a bridge +func (s *GBoxServer) RemoveBridge(deviceSerial string) { + s.bridgeManager.RemoveBridge(deviceSerial) +} + +// GetBridge gets a bridge by device serial +func (s *GBoxServer) GetBridge(deviceSerial string) (handlers.Bridge, bool) { + bridge, exists := s.bridgeManager.GetBridge(deviceSerial) + return bridge, exists +} + +// GetStaticFS returns static file system +func (s *GBoxServer) GetStaticFS() fs.FS { + return staticFiles +} + +// FindLiveViewStaticPath returns live view static path (deprecated - now embedded) +func (s *GBoxServer) FindLiveViewStaticPath() string { + return "" // Live-view files are now embedded, not external +} + +// FindStaticPath returns static path +func (s *GBoxServer) FindStaticPath() string { + return s.findStaticPath() +} + +// StartPortForward starts port forwarding for ADB expose +// This method is kept for ServerService interface compatibility +// but ADB functionality is now handled by ADBExposeHandlers +func (s *GBoxServer) StartPortForward(boxID string, localPorts, remotePorts []int) error { + return fmt.Errorf("ADB port forwarding is now handled through API endpoints") +} + +// StopPortForward stops port forwarding for ADB expose +// This method is kept for ServerService interface compatibility +func (s *GBoxServer) StopPortForward(boxID string) error { + return fmt.Errorf("ADB port forwarding is now handled through API endpoints") +} + +// ListPortForwards lists all active port forwards +// This method is kept for ServerService interface compatibility +func (s *GBoxServer) ListPortForwards() interface{} { + return map[string]interface{}{ + "forwards": []interface{}{}, + "count": 0, + "message": "ADB port forwarding is now handled through API endpoints", + } +} + // Helper function to send JSON responses func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) { w.Header().Set("Content-Type", "application/json") diff --git a/packages/cli/internal/server/static/adb-expose.html b/packages/cli/internal/server/static/adb-expose.html index 68476557..680b60a2 100644 --- a/packages/cli/internal/server/static/adb-expose.html +++ b/packages/cli/internal/server/static/adb-expose.html @@ -1,6 +1,7 @@ + ADB Expose - GBOX Local Server diff --git a/packages/cli/internal/server/static/index.html b/packages/cli/internal/server/static/index.html index 05815a3e..d6480fba 100644 --- a/packages/cli/internal/server/static/index.html +++ b/packages/cli/internal/server/static/index.html @@ -146,7 +146,7 @@

GBOX Local Server

- +
📱

Device Connect

diff --git a/packages/cli/internal/server/static/live-view.html b/packages/cli/internal/server/static/live-view.html deleted file mode 100644 index 30f754da..00000000 --- a/packages/cli/internal/server/static/live-view.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - Live View - GBOX Local Server - - - - - -

- - diff --git a/packages/cli/internal/server/static_handlers.go b/packages/cli/internal/server/static_handlers.go deleted file mode 100644 index a92be479..00000000 --- a/packages/cli/internal/server/static_handlers.go +++ /dev/null @@ -1,255 +0,0 @@ -package server - -import ( - "fmt" - "io" - "io/fs" - "net/http" - "os" - "path/filepath" - "strings" -) - -// StaticFileConfig represents configuration for serving static files -type StaticFileConfig struct { - // BasePath is the base directory to serve files from - BasePath string - // FallbackFile is the file to serve if the requested file is not found - FallbackFile string - // ContentType is the MIME type to set (if empty, will be auto-detected) - ContentType string - // RedirectPath is the path to redirect to if no fallback is available - RedirectPath string -} - -// serveStaticFile serves a static file with the given configuration -func (s *GBoxServer) serveStaticFile(w http.ResponseWriter, r *http.Request, config StaticFileConfig) { - // Extract the requested file path from the URL - requestedPath := strings.TrimPrefix(r.URL.Path, "/") - if requestedPath == "" { - requestedPath = "index.html" - } - - // Try to serve the requested file - filePath := filepath.Join(config.BasePath, requestedPath) - if s.tryServeFile(w, filePath, config.ContentType) { - return - } - - // Try to serve fallback file - if config.FallbackFile != "" { - fallbackPath := filepath.Join(config.BasePath, config.FallbackFile) - if s.tryServeFile(w, fallbackPath, config.ContentType) { - return - } - } - - // Try to serve from server static directory as last resort - if config.BasePath != s.findStaticPath() { - serverStaticPath := s.findStaticPath() - if serverStaticPath != "" { - serverFilePath := filepath.Join(serverStaticPath, requestedPath) - if s.tryServeFile(w, serverFilePath, config.ContentType) { - return - } - - if config.FallbackFile != "" { - serverFallbackPath := filepath.Join(serverStaticPath, config.FallbackFile) - if s.tryServeFile(w, serverFallbackPath, config.ContentType) { - return - } - } - } - } - - // If redirect path is specified, redirect - if config.RedirectPath != "" { - http.Redirect(w, r, config.RedirectPath, http.StatusTemporaryRedirect) - return - } - - // Final fallback: simple error message - s.serveErrorPage(w, requestedPath) -} - -// tryServeFile attempts to serve a file and returns true if successful -func (s *GBoxServer) tryServeFile(w http.ResponseWriter, filePath string, contentType string) bool { - // Check if file exists - if _, err := os.Stat(filePath); err != nil { - return false - } - - // Open and serve the file - file, err := os.Open(filePath) - if err != nil { - return false - } - defer file.Close() - - // Set content type - if contentType == "" { - contentType = s.getContentType(filePath) - } - w.Header().Set("Content-Type", contentType) - - // Copy file content to response - _, err = io.Copy(w, file) - return err == nil -} - -// tryServeEmbeddedFile attempts to serve a file from embedded FS and returns true if successful -func (s *GBoxServer) tryServeEmbeddedFile(w http.ResponseWriter, fsys fs.FS, filePath string, contentType string) bool { - // Check if file exists in embedded FS - if _, err := fs.Stat(fsys, filePath); err != nil { - return false - } - - // Open and serve the file - file, err := fsys.Open(filePath) - if err != nil { - return false - } - defer file.Close() - - // Set content type - if contentType == "" { - contentType = s.getContentType(filePath) - } - w.Header().Set("Content-Type", contentType) - - // Copy file content to response - _, err = io.Copy(w, file) - return err == nil -} - -// getContentType determines the MIME type based on file extension -func (s *GBoxServer) getContentType(filePath string) string { - ext := strings.ToLower(filepath.Ext(filePath)) - switch ext { - case ".html", ".htm": - return "text/html; charset=utf-8" - case ".css": - return "text/css; charset=utf-8" - case ".js": - return "application/javascript; charset=utf-8" - case ".json": - return "application/json; charset=utf-8" - case ".png": - return "image/png" - case ".jpg", ".jpeg": - return "image/jpeg" - case ".gif": - return "image/gif" - case ".svg": - return "image/svg+xml" - case ".ico": - return "image/x-icon" - case ".woff": - return "font/woff" - case ".woff2": - return "font/woff2" - case ".ttf": - return "font/ttf" - case ".eot": - return "application/vnd.ms-fontobject" - default: - return "text/plain; charset=utf-8" - } -} - -// serveErrorPage serves a simple error page -func (s *GBoxServer) serveErrorPage(w http.ResponseWriter, requestedPath string) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusNotFound) - - html := fmt.Sprintf(` - - - Not Found - GBOX Local Server - - - - -
-

404 - Not Found

-

The requested file "%s" was not found.

- ← Back to Home -
- -`, requestedPath) - - fmt.Fprint(w, html) -} - -// handleLiveViewHTML serves the live-view.html file -func (s *GBoxServer) handleLiveViewHTML(w http.ResponseWriter, r *http.Request) { - config := StaticFileConfig{ - BasePath: s.findLiveViewStaticPath(), - FallbackFile: "index.html", - RedirectPath: "/live-view", - } - s.serveStaticFile(w, r, config) -} - -// handleLiveViewAssets serves assets from the live-view static directory -func (s *GBoxServer) handleLiveViewAssets(w http.ResponseWriter, r *http.Request) { - liveViewPath := s.findLiveViewStaticPath() - if liveViewPath == "" { - http.NotFound(w, r) - return - } - - // Remove /assets/ prefix and serve from live-view static directory - requestedPath := strings.TrimPrefix(r.URL.Path, "/assets/") - filePath := filepath.Join(liveViewPath, "assets", requestedPath) - - // Set appropriate content type - contentType := s.getContentType(filePath) - - if s.tryServeFile(w, filePath, contentType) { - return - } - - http.NotFound(w, r) -} - -// handleLiveView serves the live-view application -func (s *GBoxServer) handleLiveView(w http.ResponseWriter, r *http.Request) { - liveViewPath := s.findLiveViewStaticPath() - - // Note: embedded files removed - using external files only - - // Fallback to external files or placeholder - config := StaticFileConfig{ - BasePath: liveViewPath, - FallbackFile: "live-view.html", - } - s.serveStaticFile(w, r, config) -} - -// handleAdbExposeUI serves the ADB Expose management interface -func (s *GBoxServer) handleAdbExposeUI(w http.ResponseWriter, r *http.Request) { - config := StaticFileConfig{ - BasePath: s.findStaticPath(), - FallbackFile: "adb-expose.html", - } - s.serveStaticFile(w, r, config) -} - -// handleStatic serves general static files from the server static directory -func (s *GBoxServer) handleStatic(w http.ResponseWriter, r *http.Request) { - config := StaticFileConfig{ - BasePath: s.findStaticPath(), - } - s.serveStaticFile(w, r, config) -} diff --git a/packages/cli/internal/util/logger.go b/packages/cli/internal/util/logger.go index 1416721c..7507f702 100644 --- a/packages/cli/internal/util/logger.go +++ b/packages/cli/internal/util/logger.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "log/slog" + "strings" ) // Logger wraps slog and provides traditional log.Printf style methods @@ -61,6 +62,50 @@ type logWriter struct { } func (w *logWriter) Write(p []byte) (n int, err error) { - w.logger.Info(string(p)) + // Split by lines and log each non-empty line to avoid empty log entries + content := strings.TrimSpace(string(p)) + if content != "" { + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + w.logger.Info(line) + } + } + } + return len(p), nil +} + +// PrefixLogWriter implements io.Writer for logging with a prefix +type PrefixLogWriter struct { + prefix string + logger *slog.Logger +} + +// NewPrefixLogWriter creates a new PrefixLogWriter +func NewPrefixLogWriter(prefix string) *PrefixLogWriter { + return &PrefixLogWriter{ + prefix: prefix, + logger: GetLogger(), + } +} + +func (w *PrefixLogWriter) Write(p []byte) (n int, err error) { + // Split by lines and log each non-empty line + lines := strings.Split(strings.TrimSpace(string(p)), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + // Filter out verbose scrcpy DEBUG and VERBOSE messages + if w.prefix == "[scrcpy-out]" && (strings.Contains(line, "DEBUG:") || strings.Contains(line, "VERBOSE:")) { + // Log DEBUG/VERBOSE messages at debug level instead of info + if IsVerbose() { + w.logger.Debug(w.prefix+" "+line) + } + } else { + w.logger.Info(w.prefix+" "+line) + } + } + } return len(p), nil } \ No newline at end of file diff --git a/packages/cli/internal/util/random.go b/packages/cli/internal/util/random.go new file mode 100644 index 00000000..c0eff243 --- /dev/null +++ b/packages/cli/internal/util/random.go @@ -0,0 +1,20 @@ +package util + +import ( + "crypto/rand" + "encoding/hex" +) + +// GenerateRandomString generates a random string of the specified length using hex encoding. +func GenerateRandomString(length int) string { + bytes := make([]byte, (length+1)/2) // Need half the bytes for hex encoding + if _, err := rand.Read(bytes); err != nil { + // Fallback to a simple timestamp-based approach if crypto/rand fails + return hex.EncodeToString([]byte("fallback"))[:length] + } + result := hex.EncodeToString(bytes) + if len(result) > length { + return result[:length] + } + return result +} diff --git a/packages/cli/internal/util/verbose.go b/packages/cli/internal/util/verbose.go index 28a54fcb..c2a5df14 100644 --- a/packages/cli/internal/util/verbose.go +++ b/packages/cli/internal/util/verbose.go @@ -1,23 +1,130 @@ package util import ( + "context" + "fmt" "log/slog" "os" + "strings" ) var logger *slog.Logger +// ANSI color codes +const ( + ColorReset = "\033[0m" + ColorRed = "\033[31m" + ColorYellow = "\033[33m" + ColorBlue = "\033[34m" + ColorGreen = "\033[32m" + ColorCyan = "\033[36m" + ColorGray = "\033[90m" +) + +// PrettyHandler is a custom slog handler that provides colorized, human-readable output +type PrettyHandler struct { + level slog.Level +} + +// NewPrettyHandler creates a new PrettyHandler +func NewPrettyHandler(level slog.Level) *PrettyHandler { + return &PrettyHandler{level: level} +} + +// Enabled reports whether the handler handles records at the given level +func (h *PrettyHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +// Handle formats and outputs the log record +func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error { + // Format time as HH:MM:SS + timeStr := r.Time.Format("15:04:05") + + // Get level color and symbol + var levelColor, levelStr string + switch r.Level { + case slog.LevelDebug: + levelColor = ColorGray + levelStr = "DEBUG" + case slog.LevelInfo: + levelColor = ColorBlue + levelStr = "INFO " + case slog.LevelWarn: + levelColor = ColorYellow + levelStr = "WARN " + case slog.LevelError: + levelColor = ColorRed + levelStr = "ERROR" + default: + levelColor = ColorReset + levelStr = " " + } + + // Format message + msg := r.Message + + // Collect attributes + var attrs []string + r.Attrs(func(a slog.Attr) bool { + // Format key-value pairs nicely + value := a.Value.String() + // Remove quotes from strings for cleaner output + if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { + value = strings.Trim(value, `"`) + } + attrs = append(attrs, fmt.Sprintf("%s=%s", ColorCyan+a.Key+ColorReset, value)) + return true + }) + + // Build final output + var output strings.Builder + output.WriteString(fmt.Sprintf("%s%s%s [%s%s%s] %s", + ColorGray, timeStr, ColorReset, + levelColor, levelStr, ColorReset, + msg)) + + // Add attributes if any + if len(attrs) > 0 { + output.WriteString(" ") + output.WriteString(strings.Join(attrs, " ")) + } + + output.WriteString("\n") + fmt.Print(output.String()) + return nil +} + +// WithAttrs returns a new Handler whose attributes consist of both the receiver's attributes and the arguments +func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h // For simplicity, not implementing attribute preservation +} + +// WithGroup returns a new Handler with the given group appended to the receiver's existing groups +func (h *PrettyHandler) WithGroup(name string) slog.Handler { + return h // For simplicity, not implementing groups +} + + // InitLogger initializes the global slog logger with appropriate level func InitLogger(verbose bool) { - opts := &slog.HandlerOptions{ - Level: slog.LevelInfo, // Default level + level := slog.LevelInfo + if verbose { + level = slog.LevelDebug } - if verbose { - opts.Level = slog.LevelDebug + var handler slog.Handler + + // Check if we should use structured logging (for production/server environments) + if UseStructuredLogging() { + // Use structured JSON or text handler for production + opts := &slog.HandlerOptions{Level: level} + handler = slog.NewTextHandler(os.Stdout, opts) + } else { + // Use pretty handler for development + handler = NewPrettyHandler(level) } - handler := slog.NewTextHandler(os.Stdout, opts) logger = slog.New(handler) slog.SetDefault(logger) } @@ -39,4 +146,29 @@ func IsVerbose() bool { } } return false +} + +// UseStructuredLogging determines whether to use structured logging format +// This is useful for production/server environments where logs need to be parsed +func UseStructuredLogging() bool { + // Check environment variable + if env := os.Getenv("LOG_FORMAT"); env != "" { + switch strings.ToLower(env) { + case "structured": + return true + case "pretty": + return false + } + } + + // Check if running in container or CI environment (production indicators) + if os.Getenv("CONTAINER") != "" || + os.Getenv("CI") != "" || + os.Getenv("KUBERNETES_SERVICE_HOST") != "" || + os.Getenv("DOCKER_CONTAINER") != "" { + return true + } + + // Default to pretty logging for local development (including server command) + return false } \ No newline at end of file diff --git a/packages/live-view/index.html b/packages/live-view/index.html index e6e057c6..b84bd1de 100644 --- a/packages/live-view/index.html +++ b/packages/live-view/index.html @@ -15,11 +15,18 @@ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a1a; color: #ffffff; - overflow: hidden; + overflow: auto; + min-height: 100vh; } #root { - width: 100vw; + width: 100%; height: 100vh; + margin: 0; + padding: 0; + border-radius: 0; + overflow: hidden; + display: flex; + flex-direction: column; } diff --git a/packages/live-view/pnpm-lock.yaml b/packages/live-view/pnpm-lock.yaml index 5b7eb115..414262fd 100644 --- a/packages/live-view/pnpm-lock.yaml +++ b/packages/live-view/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 15.3.1(rollup@4.50.1) '@rollup/plugin-typescript': specifier: ^11.1.5 - version: 11.1.6(rollup@4.50.1)(typescript@5.9.2) + version: 11.1.6(rollup@4.50.1)(tslib@1.14.1)(typescript@5.9.2) '@types/react': specifier: ^18.2.43 version: 18.3.24 @@ -1941,6 +1941,9 @@ packages: peerDependencies: typescript: '>=4.2.0' + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2328,13 +2331,14 @@ snapshots: optionalDependencies: rollup: 4.50.1 - '@rollup/plugin-typescript@11.1.6(rollup@4.50.1)(typescript@5.9.2)': + '@rollup/plugin-typescript@11.1.6(rollup@4.50.1)(tslib@1.14.1)(typescript@5.9.2)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.50.1) resolve: 1.22.10 typescript: 5.9.2 optionalDependencies: rollup: 4.50.1 + tslib: 1.14.1 '@rollup/pluginutils@5.3.0(rollup@4.50.1)': dependencies: @@ -4143,6 +4147,9 @@ snapshots: dependencies: typescript: 5.9.2 + tslib@1.14.1: + optional: true + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/packages/live-view/src/components/AndroidLiveView.module.css b/packages/live-view/src/components/AndroidLiveView.module.css index f53c5793..a9ea22a8 100644 --- a/packages/live-view/src/components/AndroidLiveView.module.css +++ b/packages/live-view/src/components/AndroidLiveView.module.css @@ -1,25 +1,124 @@ .container { display: flex; height: 100vh; + max-height: 100vh; + width: 100vw; + max-width: 100vw; background: #1a1a1a; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + flex-direction: column; + position: relative; + border-radius: 0; + overflow: hidden; + box-sizing: border-box; +} + +/* Mode Switcher */ +.modeSwitcher { + margin-bottom: 16px; + padding: 12px; + background: rgba(0, 0, 0, 0.3); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + box-sizing: border-box; +} + +.modeSwitcherTitle { + color: #ffffff; + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + text-align: center; +} + +.modeButtonGroup { + display: flex; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.2); + overflow: hidden; +} + +.modeBtn { + flex: 1; + padding: 8px 12px; + border: none; + background: transparent; + color: #fff; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.2s ease; + text-align: center; + position: relative; + box-sizing: border-box; + min-width: 0; +} + +.modeBtn:not(:last-child)::after { + content: ''; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 1px; + height: 60%; + background: rgba(255, 255, 255, 0.2); +} + +.modeBtn:first-child { + border-top-left-radius: 7px; + border-bottom-left-radius: 7px; +} + +.modeBtn:last-child { + border-top-right-radius: 7px; + border-bottom-right-radius: 7px; +} + +.modeBtn:hover { + background: rgba(255, 255, 255, 0.15); +} + +.modeBtn.active { + background: #667eea; + color: #fff; +} + +.modeBtn.active:not(:last-child)::after { + display: none; +} + +.contentWrapper { + display: flex; + flex: 1; + position: relative; + overflow: hidden; + min-width: 0; } .sidebar { - width: 300px; + width: 320px; + min-width: 300px; + max-width: 320px; background: #2d2d2d; - padding: 20px; + padding: 16px; overflow-y: auto; + overflow-x: hidden; + flex-shrink: 0; + box-sizing: border-box; } .mainContent { flex: 1; display: flex; - flex-direction: row; + flex-direction: column; background: #000; - overflow: visible; + overflow: hidden; position: relative; + height: 100%; + min-height: 0; } .videoContainer { @@ -27,11 +126,33 @@ position: relative; background: #000; display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; + box-sizing: border-box; +} + +.videoContent { + flex: 1; + position: relative; + display: flex; + flex-direction: row; + overflow: hidden; + min-height: 0; + box-sizing: border-box; +} + +.videoMainArea { + flex: 1; + position: relative; + display: flex; align-items: center; justify-content: center; overflow: hidden; min-height: 0; - padding: 20px 20px 80px 20px; + min-width: 0; + padding: 8px; + box-sizing: border-box; } .videoWrapper { @@ -43,14 +164,17 @@ height: 100%; max-width: 100%; max-height: 100%; + overflow: hidden; + min-height: 0; + min-width: 0; } .video { object-fit: contain; display: block; - width: 100%; - height: 100%; + width: auto; + height: auto; max-width: 100%; max-height: 100%; cursor: pointer; @@ -58,6 +182,7 @@ -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; + transition: width 0.2s ease, height 0.2s ease; } .video.dragging { @@ -90,35 +215,49 @@ } .controlsArea { - width: 100px; + position: relative; + right: 0; + top: 0; + transform: none; + z-index: 1000; + background: rgba(0, 0, 0, 0.9); + pointer-events: auto; + width: 120px; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; - background: transparent; - position: relative; } .statsArea { - position: absolute; + position: relative; bottom: 0; - right: 0; left: 0; - height: 60px; + right: 0; + height: 24px; display: flex; - align-items: flex-end; - justify-content: flex-end; - padding: 10px 20px; + align-items: center; + justify-content: center; pointer-events: none; z-index: 10; + background: rgba(0, 0, 0, 0.9); + border-top: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; + font-weight: bolder; } .stats { - background: rgba(0, 0, 0, 0.7); - padding: 8px 12px; - border-radius: 5px; - font-size: 12px; - color: #ccc; + background: transparent; + padding: 4px 12px; + border-radius: 0; + font-size: 11px; + color: #888; pointer-events: auto; + display: flex; + align-items: center; + gap: 12px; + backdrop-filter: none; + border: none; } .toggleButton { @@ -152,4 +291,64 @@ .toggleButton:active { background: rgba(255, 255, 255, 0.3); transform: translateY(-50%) scale(0.95); +} + +/* Responsive Design */ +@media (max-width: 1200px) { + .sidebar { + width: 300px; + min-width: 280px; + } + + .videoContainer { + padding: 8px; + } +} + +/* Ensure video fits properly on smaller screens */ +@media (max-height: 800px) { + .videoContainer { + padding: 12px; + } +} + +@media (max-height: 600px) { + .videoContainer { + padding: 8px; + } +} + +@media (max-width: 768px) { + .contentWrapper { + flex-direction: column; + } + + .sidebar { + width: 100%; + height: 220px; + min-width: unset; + order: 2; + overflow-y: auto; + padding: 16px; + } + + .mainContent { + order: 1; + height: calc(100% - 220px); + } + + .videoContainer { + padding: 8px; + } + + .modeSwitcher { + margin-bottom: 12px; + padding: 10px; + } + + .modeBtn { + padding: 6px 10px; + font-size: 11px; + margin-bottom: 4px; + } } \ No newline at end of file diff --git a/packages/live-view/src/components/AndroidLiveView.tsx b/packages/live-view/src/components/AndroidLiveView.tsx index b048a13d..c206c017 100644 --- a/packages/live-view/src/components/AndroidLiveView.tsx +++ b/packages/live-view/src/components/AndroidLiveView.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useRef, useState } from 'react'; import { AndroidLiveViewProps, Stats } from '../types'; import { WebRTCClient } from '../lib/webrtc-client'; +import { MSEClient } from '../lib/mse-client'; +import { H264Client } from '../lib/h264-client'; import { DeviceList } from './DeviceList'; import { ControlButtons } from './ControlButtons'; import { @@ -15,8 +17,9 @@ import { import styles from './AndroidLiveView.module.css'; export const AndroidLiveView: React.FC = ({ - apiUrl = '/api', + apiUrl = 'http://localhost:29888/api', wsUrl = 'ws://localhost:8080/ws', + mode = 'h264', deviceSerial, autoConnect = false, showControls = true, @@ -28,12 +31,15 @@ export const AndroidLiveView: React.FC = ({ className, }) => { const videoRef = useRef(null); - const clientRef = useRef(null); + const videoWrapperRef = useRef(null); + // Use a polymorphic client ref so we can switch among WebRTC/Streaming/H264 + const clientRef = useRef(null); const touchIndicatorRef = useRef(null); const [connectionStatus, setConnectionStatus] = useState(''); const [isConnected, setIsConnected] = useState(false); const [stats, setStats] = useState({ fps: 0, resolution: '', latency: 0 }); const [keyboardCaptureEnabled] = useState(true); + const [currentMode, setCurrentMode] = useState<'webrtc' | 'stream' | 'h264'>(mode as 'webrtc' | 'stream' | 'h264'); // Use custom hooks for different functionalities const { devices, currentDevice, loading, setCurrentDevice, loadDevices } = useDeviceManager({ @@ -46,13 +52,13 @@ export const AndroidLiveView: React.FC = ({ }); const { handleSmartPaste, handleSmartCopy } = useClipboardHandler({ - clientRef, + clientRef: clientRef as any, isConnected, keyboardCaptureEnabled, }); const { handleKeyDown, handleKeyUp } = useKeyboardHandler({ - clientRef, + clientRef: clientRef as any, isConnected, keyboardCaptureEnabled, onSmartPaste: handleSmartPaste, @@ -60,16 +66,16 @@ export const AndroidLiveView: React.FC = ({ }); const { isDragging, touchPosition, handleMouseInteraction, handleTouchInteraction, handleMouseLeave } = useMouseHandler({ - clientRef, + clientRef: clientRef as any, }); const { handleClick } = useClickHandler({ - clientRef, + clientRef: clientRef as any, isConnected, }); const { handleControlAction, handleIMESwitch } = useControlHandler({ - clientRef, + clientRef: clientRef as any, isConnected, }); @@ -80,30 +86,139 @@ export const AndroidLiveView: React.FC = ({ isConnected, }); - // Handle window resize for keyframe requests + // Video resize handler - centralized and debounced + const resizeVideo = React.useCallback(() => { + if (!videoRef.current || !videoWrapperRef.current) return; + + const video = videoRef.current; + const videoWrapper = videoWrapperRef.current; + const container = videoWrapper.parentElement; // videoMainArea + + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(container); + const paddingRight = parseInt(computedStyle.paddingRight) || 8; + const paddingLeft = parseInt(computedStyle.paddingLeft) || 8; + const paddingTop = parseInt(computedStyle.paddingTop) || 8; + const paddingBottom = parseInt(computedStyle.paddingBottom) || 8; + + const availableWidth = containerRect.width - paddingLeft - paddingRight; + const availableHeight = containerRect.height - paddingTop - paddingBottom; + + // Get actual video dimensions, fallback to default mobile aspect ratio + let videoWidth = video.videoWidth || 1080; + let videoHeight = video.videoHeight || 2340; + + const aspectRatio = videoWidth / videoHeight; + + // Calculate optimal dimensions + const widthBasedHeight = availableWidth / aspectRatio; + const heightBasedWidth = availableHeight * aspectRatio; + + let newWidth, newHeight; + + if (widthBasedHeight <= availableHeight) { + // Width-constrained + newWidth = availableWidth; + newHeight = widthBasedHeight; + } else { + // Height-constrained + newWidth = heightBasedWidth; + newHeight = availableHeight; + } + + // Apply dimensions + video.style.width = `${Math.floor(newWidth)}px`; + video.style.height = `${Math.floor(newHeight)}px`; + video.style.maxWidth = '100%'; + video.style.maxHeight = '100%'; + video.style.objectFit = 'contain'; + + videoWrapper.style.width = `${Math.floor(newWidth)}px`; + videoWrapper.style.height = `${Math.floor(newHeight)}px`; + videoWrapper.style.maxWidth = '100%'; + videoWrapper.style.maxHeight = '100%'; + + console.log('[Video] Resized:', { + dimensions: { width: Math.floor(newWidth), height: Math.floor(newHeight) }, + videoSize: { width: videoWidth, height: videoHeight }, + container: { width: containerRect.width, height: containerRect.height }, + available: { width: availableWidth, height: availableHeight }, + aspectRatio + }); + }, []); + + // Debounced resize handler for window resize events + const debouncedResize = React.useMemo(() => { + let timeoutId: number; + return () => { + clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + resizeVideo(); + // Request keyframe on resize if connected + if (clientRef.current && isConnected) { + console.log('[WebRTC] Window resized, requesting keyframe'); + clientRef.current.requestKeyframe(); + } + }, 100); + }; + }, [resizeVideo, isConnected]); + + // Window resize listener - always active, independent of connection state useEffect(() => { - const handleResize = () => { - if (clientRef.current && isConnected) { - console.log('[WebRTC] Window resized, requesting keyframe'); - clientRef.current.requestKeyframe(); - } + window.addEventListener('resize', debouncedResize); + return () => window.removeEventListener('resize', debouncedResize); + }, [debouncedResize]); + + // Video event listeners for metadata and resize events + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handleVideoLoadedMetadata = () => { + console.log('[Video] Metadata loaded, resizing'); + setTimeout(resizeVideo, 100); }; - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, [isConnected]); + const handleVideoResize = () => { + console.log('[Video] Video element resized'); + setTimeout(resizeVideo, 50); + }; + video.addEventListener('loadedmetadata', handleVideoLoadedMetadata); + video.addEventListener('resize', handleVideoResize); + return () => { + video.removeEventListener('loadedmetadata', handleVideoLoadedMetadata); + video.removeEventListener('resize', handleVideoResize); + }; + }, [resizeVideo]); - // Initialize WebRTC client + // Initial resize and connection state change handler useEffect(() => { - if (!videoRef.current) return; + const timer = setTimeout(resizeVideo, isConnected ? 500 : 100); + return () => clearTimeout(timer); + }, [resizeVideo, isConnected]); + + + + // Initialize client based on mode + useEffect(() => { + console.log('[AndroidLiveView] Initializing client for mode:', currentMode); + console.log('[AndroidLiveView] Video ref:', videoRef.current); + console.log('[AndroidLiveView] showControls:', showControls, 'showAndroidControls:', showAndroidControls); + + if (!videoRef.current) { + console.error('[AndroidLiveView] Video ref is null, cannot initialize client'); + return; + } // Auto-focus video element for keyboard input videoRef.current.focus(); - clientRef.current = new WebRTCClient(videoRef.current, { - onConnectionStateChange: (state, message) => { + const clientOptions = { + onConnectionStateChange: (state: "connecting" | "connected" | "disconnected" | "error", message?: string) => { setConnectionStatus(message || ''); setIsConnected(state === 'connected'); @@ -119,19 +234,81 @@ export const AndroidLiveView: React.FC = ({ onDisconnect?.(); } }, - onError: (error) => { - console.error('WebRTC error:', error); + onError: (error: Error) => { + console.error(`${currentMode.toUpperCase()} error:`, error); onError?.(error); }, - onStatsUpdate: (newStats) => { + onStatsUpdate: (newStats: any) => { setStats(prev => ({ ...prev, ...newStats })); }, - }); + }; + + if (currentMode === 'webrtc') { + if (videoRef.current) { + clientRef.current = new WebRTCClient(videoRef.current, clientOptions); + } + } else if (currentMode === 'stream') { + if (videoRef.current) { + clientRef.current = new MSEClient(videoRef.current, clientOptions); + } + } else if (currentMode === 'h264') { + // h264: use the video element's parent container to host canvas + // Don't replace the video element, just hide it and add canvas alongside + console.log('[AndroidLiveView] Setting up H264 client...'); + if (videoRef.current && videoRef.current.parentElement) { + const parent = videoRef.current.parentElement; + console.log('[AndroidLiveView] Parent element found:', parent); + + // Clean up any existing H264 container first + const existingContainer = document.getElementById('h264-container'); + if (existingContainer && existingContainer.parentElement) { + existingContainer.parentElement.removeChild(existingContainer); + } + + // Hide the video element for H264 mode + videoRef.current.style.display = 'none'; + + // Create container for H264 canvas + const h264Container = document.createElement('div'); + h264Container.style.width = '100%'; + h264Container.style.height = '100%'; + h264Container.style.position = 'absolute'; + h264Container.style.top = '0'; + h264Container.style.left = '0'; + h264Container.id = 'h264-container'; + parent.appendChild(h264Container); + + try { + clientRef.current = new H264Client(h264Container, clientOptions); + console.log('[AndroidLiveView] H264 client created successfully'); + } catch (error) { + console.error('[AndroidLiveView] Failed to create H264 client:', error); + } + } else { + console.error('[AndroidLiveView] Video element or parent not found for H264 mode'); + } + } return () => { - clientRef.current?.cleanup(); + // Clean up client first + if (clientRef.current) { + clientRef.current.cleanup(); + clientRef.current = null; + } + + // Cleanup H264 mode: remove H264 container and restore video element + if (currentMode === 'h264' && videoRef.current) { + // Show the video element again + videoRef.current.style.display = ''; + + // Remove H264 container if it exists + const h264Container = document.getElementById('h264-container'); + if (h264Container && h264Container.parentElement) { + h264Container.parentElement.removeChild(h264Container); + } + } }; - }, []); + }, [currentMode]); // Auto-connect to specified device @@ -142,14 +319,37 @@ export const AndroidLiveView: React.FC = ({ }, [autoConnect, deviceSerial]); const handleConnect = async (serial: string) => { - if (!clientRef.current) return; - + console.log('[AndroidLiveView] handleConnect called with serial:', serial); + console.log('[AndroidLiveView] Current mode:', currentMode); + console.log('[AndroidLiveView] Client ref current:', clientRef.current); + + if (!clientRef.current) { + console.error('[AndroidLiveView] Client not initialized, cannot connect to device:', serial); + console.error('[AndroidLiveView] Current mode:', currentMode); + console.error('[AndroidLiveView] Video ref:', videoRef.current); + return; + } + try { - // Directly connect via WebSocket (no need for API pre-connection) + console.log('[AndroidLiveView] Setting current device to:', serial); setCurrentDevice(serial); - await clientRef.current.connect(serial, wsUrl); + + if (currentMode === 'webrtc') { + // WebRTC mode: connect via WebSocket + console.log('[AndroidLiveView] Connecting via WebRTC with wsUrl:', wsUrl); + await (clientRef.current as WebRTCClient).connect(serial, wsUrl); + } else if (currentMode === 'h264') { + // H264 mode: connect via HTTP API + console.log('[AndroidLiveView] Connecting via H264 with apiUrl:', apiUrl); + await (clientRef.current as H264Client).connect(serial, apiUrl); + } else { + // Streaming mode: connect via HTTP API + console.log('[AndroidLiveView] Connecting via MSE with apiUrl:', apiUrl); + await (clientRef.current as MSEClient).connect(serial, apiUrl); + } + console.log('[AndroidLiveView] Connection attempt completed for:', serial); } catch (error) { - console.error('Connection failed:', error); + console.error('[AndroidLiveView] Connection failed:', error); onError?.(error as Error); } }; @@ -173,84 +373,142 @@ export const AndroidLiveView: React.FC = ({ + const handleModeSwitch = async (newMode: 'webrtc' | 'stream' | 'h264') => { + if (isConnected) { + await handleDisconnect(); + // Wait a bit for cleanup to complete + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + // Clean up existing client + if (clientRef.current) { + clientRef.current.cleanup(); + clientRef.current = null; + } + + setCurrentMode(newMode); + + // Update URL to reflect current mode + const url = new URL(window.location.href); + if (newMode === 'stream') { + url.searchParams.delete('mode'); // Default mode + } else { + url.searchParams.set('mode', newMode); + } + window.history.replaceState({}, '', url.toString()); + }; + return (
- {showDeviceList && ( -
- -
- )} - -
-
-
-
- -
-
- - {showControls && showAndroidControls && isConnected && ( -
- {}} +
+ {showDeviceList && ( +
+ {/* Mode Switcher */} +
+
Streaming Mode
+
+ + + +
+
+ +
)} - {showControls && ( -
-
-
Resolution: {stats.resolution || '-'}
-
FPS: {stats.fps || '-'}
-
Latency: {stats.latency ? `${stats.latency}ms` : '-'}
+
+
+
+
+
+
+ + {showControls && showAndroidControls && ( +
+ {}} + showDisconnect={currentMode === 'h264'} + /> +
+ )}
+ + {showControls && ( +
+
+
Resolution: {stats.resolution || '-'}
+
FPS: {stats.fps || '-'}
+
Latency: {stats.latency ? `${stats.latency}ms` : '-'}
+
+
+ )}
- )} +
); diff --git a/packages/live-view/src/components/ControlButtons.module.css b/packages/live-view/src/components/ControlButtons.module.css index 3025dce1..58db2d30 100644 --- a/packages/live-view/src/components/ControlButtons.module.css +++ b/packages/live-view/src/components/ControlButtons.module.css @@ -2,16 +2,18 @@ display: flex; flex-direction: column; gap: 6px; - background: rgba(45, 45, 45, 0.95); - padding: 12px 8px; + background: rgba(0, 0, 0, 0.8); + padding: 12px 10px; border-radius: 16px; z-index: 1000; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); margin: 0; box-sizing: border-box; backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.2); min-width: 60px; + max-height: 80vh; + overflow-y: auto; } @@ -19,7 +21,7 @@ width: 44px; height: 44px; border: none; - background: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.1); border-radius: 12px; cursor: pointer; display: flex; @@ -47,8 +49,8 @@ } .controlBtn svg { - width: 22px; - height: 22px; + width: 20px; + height: 20px; display: block; margin: 0; padding: 0; diff --git a/packages/live-view/src/components/ControlButtons.tsx b/packages/live-view/src/components/ControlButtons.tsx index b6440b19..0f5a41bb 100644 --- a/packages/live-view/src/components/ControlButtons.tsx +++ b/packages/live-view/src/components/ControlButtons.tsx @@ -4,8 +4,10 @@ import styles from './ControlButtons.module.css'; interface ControlButtonsProps { onAction: (action: string) => void; onIMESwitch?: () => void; + onDisconnect?: () => void; isVisible?: boolean; onToggleVisibility?: () => void; + showDisconnect?: boolean; } export const ControlButtons: React.FC = ({ onAction, onIMESwitch, isVisible = true, onToggleVisibility }) => { diff --git a/packages/live-view/src/components/DeviceList.module.css b/packages/live-view/src/components/DeviceList.module.css index c8eadf97..448c6646 100644 --- a/packages/live-view/src/components/DeviceList.module.css +++ b/packages/live-view/src/components/DeviceList.module.css @@ -36,7 +36,7 @@ } .deviceItem { - padding: 10px; + padding: 12px; margin: 5px 0; background: #3d3d3d; border-radius: 5px; @@ -46,6 +46,7 @@ display: flex; justify-content: space-between; align-items: center; + gap: 10px; } .deviceItem:hover { @@ -59,6 +60,8 @@ .deviceInfo { flex: 1; + min-width: 0; + overflow: hidden; } .deviceSerial { @@ -89,12 +92,16 @@ background: #cc0000; color: white; border: none; - border-radius: 3px; + border-radius: 4px; cursor: pointer; - font-size: 12px; + font-size: 11px; + font-weight: 500; transition: all 0.2s; margin-left: 10px; opacity: 0.7; + min-width: 45px; + white-space: nowrap; + text-align: center; } .deviceItem.connected:hover .disconnectBtn { diff --git a/packages/live-view/src/components/DeviceList.tsx b/packages/live-view/src/components/DeviceList.tsx index c8e993ed..62d2def1 100644 --- a/packages/live-view/src/components/DeviceList.tsx +++ b/packages/live-view/src/components/DeviceList.tsx @@ -29,20 +29,21 @@ export const DeviceList: React.FC = ({ } if (device.connected || (currentDevice === device.serial && isConnected)) { return device.videoWidth && device.videoHeight - ? `已连接 - ${device.videoWidth}x${device.videoHeight}` - : '已连接'; + ? `Connected - ${device.videoWidth}x${device.videoHeight}` + : 'Connected'; } return device.state; }; const getStatusClass = (device: Device): string => { if (currentDevice === device.serial && connectionStatus) { - if (connectionStatus.includes('正在连接') || - connectionStatus.includes('重连中') || - connectionStatus.includes('秒后重试')) { + if (connectionStatus.includes('Connecting') || + connectionStatus.includes('reconnecting') || + connectionStatus.includes('Reconnecting')) { return styles.connecting; - } else if (connectionStatus.includes('失败') || - connectionStatus.includes('断开')) { + } else if (connectionStatus.includes('failed') || + connectionStatus.includes('Failed') || + connectionStatus.includes('disconnected')) { return styles.error; } } @@ -51,17 +52,17 @@ export const DeviceList: React.FC = ({ return (
-

设备列表

+

Device List

{loading && (
- 正在加载设备... + Loading devices...
)} {!loading && devices.length === 0 && ( -
没有找到设备
+
No devices found
)} {devices.map((device) => { @@ -73,8 +74,17 @@ export const DeviceList: React.FC = ({ key={device.serial} className={`${styles.deviceItem} ${isDeviceConnected ? styles.connected : ''}`} onClick={() => { + console.log('[DeviceList] Device clicked:', { + serial: device.serial, + state: device.state, + isDeviceConnected, + canConnect: !isDeviceConnected && device.state === 'device' + }); if (!isDeviceConnected && device.state === 'device') { + console.log('[DeviceList] Calling onConnect for device:', device.serial); onConnect(device.serial); + } else { + console.log('[DeviceList] Cannot connect to device - conditions not met'); } }} > @@ -94,7 +104,7 @@ export const DeviceList: React.FC = ({ onDisconnect(); }} > - 断开 + Stop )}
diff --git a/packages/live-view/src/hooks/useDeviceManager.ts b/packages/live-view/src/hooks/useDeviceManager.ts index d2355320..d2648a1e 100644 --- a/packages/live-view/src/hooks/useDeviceManager.ts +++ b/packages/live-view/src/hooks/useDeviceManager.ts @@ -24,21 +24,29 @@ export const useDeviceManager = ({ // Load devices const loadDevices = useCallback(async () => { + console.log('[useDeviceManager] Loading devices from:', `${apiUrl}/devices`); setLoading(true); try { const response = await fetch(`${apiUrl}/devices`); + console.log('[useDeviceManager] Response status:', response.status); const data = await response.json(); + console.log('[useDeviceManager] Raw response data:', data); + console.log('[useDeviceManager] Raw devices array:', data.devices); + if (data.devices && data.devices.length > 0) { + console.log('[useDeviceManager] First raw device:', data.devices[0]); + } // Transform device data to match our interface // The API returns 'id' but we use 'serial' internally const transformedDevices = (data.devices || []).map((device: any) => ({ serial: device.id || device.serial || device.udid, - state: device.state, + state: device.status || device.state, // API returns 'status', frontend expects 'state' model: device['ro.product.model'] || device.model || 'Unknown', connected: device.connected, })); + console.log('[useDeviceManager] Transformed devices:', transformedDevices); setDevices(transformedDevices); } catch (error) { - console.error('Failed to load devices:', error); + console.error('[useDeviceManager] Failed to load devices:', error); onError?.(error as Error); } finally { setLoading(false); diff --git a/packages/live-view/src/lib/h264-client.ts b/packages/live-view/src/lib/h264-client.ts new file mode 100644 index 00000000..8f52bc76 --- /dev/null +++ b/packages/live-view/src/lib/h264-client.ts @@ -0,0 +1,456 @@ +// Types +interface H264ClientOptions { + onConnectionStateChange?: ( + state: "connecting" | "connected" | "disconnected" | "error", + message?: string + ) => void; + onError?: (error: Error) => void; + onStatsUpdate?: (stats: any) => void; +} + +// NAL Unit types +const NALU = { + SPS: 7, // Sequence Parameter Set + PPS: 8, // Picture Parameter Set + IDR: 5, // IDR frame +} as const; + +export class H264Client { + private container: HTMLElement; + private canvas: HTMLCanvasElement | null = null; + private context: CanvasRenderingContext2D | null = null; + private decoder: VideoDecoder | null = null; + private abortController: AbortController | null = null; + private opts: H264ClientOptions; + private buffer: Uint8Array = new Uint8Array(0); + private spsData: Uint8Array | null = null; + private ppsData: Uint8Array | null = null; + private animationFrameId: number | undefined; + private decodedFrames: Array<{ frame: VideoFrame; timestamp: number }> = []; + + constructor(container: HTMLElement, opts: H264ClientOptions = {}) { + this.container = container; + this.opts = opts; + this.initializeWebCodecs(); + } + + // Initialize WebCodecs decoder + private initializeWebCodecs(): void { + console.log("[H264Client] Initializing WebCodecs decoder..."); + + // Check if WebCodecs is supported + if (typeof VideoDecoder !== "function") { + console.error("[H264Client] WebCodecs not supported"); + this.opts.onError?.(new Error("WebCodecs not supported")); + return; + } + + try { + // Create canvas for rendering + this.canvas = document.createElement("canvas"); + this.canvas.style.width = "100%"; + this.canvas.style.height = "100%"; + this.canvas.style.display = "block"; + this.container.appendChild(this.canvas); + + // Get 2D context + const context = this.canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to get 2d context from canvas"); + } + this.context = context; + + // Create VideoDecoder + this.decoder = new VideoDecoder({ + output: (frame) => this.onFrameDecoded(frame), + error: (error: DOMException) => { + console.error("[H264Client] VideoDecoder error:", error); + this.opts.onError?.( + new Error(`VideoDecoder error: ${error.message}`) + ); + }, + }); + + console.log("[H264Client] WebCodecs decoder initialized successfully"); + } catch (error) { + console.error("[H264Client] WebCodecs initialization failed:", error); + this.opts.onError?.(new Error("WebCodecs initialization failed")); + } + } + + // Connect to H.264 AVC format stream + public async connect( + deviceSerial: string, + apiUrl: string = "/api" + ): Promise { + const url = `${apiUrl}/stream/video/${deviceSerial}?mode=h264&format=avc`; + console.log("[H264Client] Connecting to H.264 AVC stream:", url); + + // Reinitialize WebCodecs if decoder is not ready (e.g., after disconnect) + if (!this.decoder) { + console.log( + "[H264Client] Decoder not ready, reinitializing WebCodecs..." + ); + this.initializeWebCodecs(); + } + + if (!this.decoder) { + throw new Error("WebCodecs decoder not ready"); + } + + // Notify connecting state + this.opts.onConnectionStateChange?.( + "connecting", + "Connecting to H.264 stream..." + ); + + try { + await this.startHTTP(url); + // Notify connected state + this.opts.onConnectionStateChange?.( + "connected", + "H.264 stream connected" + ); + } catch (error) { + console.error("[H264Client] Connection failed:", error); + this.opts.onConnectionStateChange?.("error", "H.264 connection failed"); + this.opts.onError?.(error as Error); + } + } + + // Start HTTP stream + private async startHTTP(url: string): Promise { + this.abortController = new AbortController(); + const response = await fetch(url, { + signal: this.abortController.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("No response body reader available"); + } + + // Process stream data in async function + (async () => { + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + + if (value && value.length) { + // Append new data to buffer + const newBuffer = new Uint8Array(this.buffer.length + value.length); + newBuffer.set(this.buffer); + newBuffer.set(value, this.buffer.length); + this.buffer = newBuffer; + + // Process NAL units from AVC format stream + const { processedNals, remainingBuffer } = this.parseAVC( + this.buffer + ); + this.buffer = remainingBuffer; + + // Process each NAL unit + for (const nalData of processedNals) { + this.processNALUnit(nalData); + } + } + } + } catch (error) { + // Only log error if it's not an abort error (which is expected when disconnecting) + if (error instanceof Error && error.name !== "AbortError") { + console.error("[H264Client] Stream processing error:", error); + } + } + })(); + } + + // Parse H.264 AVC format stream and extract NAL units + private parseAVC(data: Uint8Array): { + processedNals: Uint8Array[]; + remainingBuffer: Uint8Array; + } { + const processedNals: Uint8Array[] = []; + let offset = 0; + + while (offset < data.length - 4) { + // Read length prefix (4 bytes, big-endian) + const length = + (data[offset] << 24) | + (data[offset + 1] << 16) | + (data[offset + 2] << 8) | + data[offset + 3]; + + offset += 4; + + // Check if we have enough data for the NAL unit + if (offset + length > data.length) { + offset -= 4; // Put back the length prefix + break; + } + + // Extract NAL unit + const nalData = data.slice(offset, offset + length); + if (nalData.length > 0) { + processedNals.push(nalData); + } + + offset += length; + } + + return { + processedNals, + remainingBuffer: data.slice(offset), + }; + } + + // Process individual NAL unit + private processNALUnit(nalData: Uint8Array): void { + if (nalData.length === 0) return; + + const nalType = nalData[0] & 0x1f; + + // Handle SPS + if (nalType === NALU.SPS) { + this.spsData = nalData; + this.tryConfigureDecoder(); + return; + } + + // Handle PPS + if (nalType === NALU.PPS) { + this.ppsData = nalData; + this.tryConfigureDecoder(); + return; + } + + // Only decode if we have SPS and PPS + if (!this.spsData || !this.ppsData) { + return; + } + + // Decode frame + this.decodeFrame(nalData); + } + + // Try to configure decoder when we have both SPS and PPS + private tryConfigureDecoder(): void { + if (!this.spsData || !this.ppsData || !this.decoder) { + return; + } + + try { + // Create AVC description + const description = this.createAVCDescription(this.spsData, this.ppsData); + + const config: VideoDecoderConfig = { + codec: "avc1.42E01E", // H.264 Baseline Profile + optimizeForLatency: true, + description, + hardwareAcceleration: "prefer-hardware" as HardwareAcceleration, + }; + + // Configure decoder + this.decoder.configure(config); + } catch (error) { + console.error("[H264Client] Decoder configuration failed:", error); + } + } + + // Create AVC description for VideoDecoderConfig (avcC format) + private createAVCDescription( + spsData: Uint8Array, + ppsData: Uint8Array + ): ArrayBuffer { + // Create avcC (AVC Configuration Record) format + // Reference: ISO/IEC 14496-15:2010 section 5.2.4.1 + + const spsLength = new Uint8Array(2); + const spsView = new DataView(spsLength.buffer); + spsView.setUint16(0, spsData.length, false); // Big-endian + + const ppsLength = new Uint8Array(2); + const ppsView = new DataView(ppsLength.buffer); + ppsView.setUint16(0, ppsData.length, false); // Big-endian + + // avcC format: [version][profile][compatibility][level][lengthSizeMinusOne][numSPS][SPS...][numPPS][PPS...] + const avcC = new Uint8Array( + 6 + 2 + spsData.length + 1 + 2 + ppsData.length + ); + let offset = 0; + + // avcC header + avcC[offset++] = 0x01; // configurationVersion + avcC[offset++] = spsData[1]; // AVCProfileIndication (from SPS) + avcC[offset++] = spsData[2]; // profile_compatibility (from SPS) + avcC[offset++] = spsData[3]; // AVCLevelIndication (from SPS) + avcC[offset++] = 0xff; // lengthSizeMinusOne (0xFF = 4-byte length) + avcC[offset++] = 0xe1; // numOfSequenceParameterSets (0xE1 = 1 SPS) + + // SPS + avcC.set(spsLength, offset); + offset += 2; + avcC.set(spsData, offset); + offset += spsData.length; + + // PPS + avcC[offset++] = 0x01; // numOfPictureParameterSets (1 PPS) + avcC.set(ppsLength, offset); + offset += 2; + avcC.set(ppsData, offset); + + return avcC.buffer; + } + + // Decode H.264 frame + private decodeFrame(nalData: Uint8Array): void { + if (!this.decoder || this.decoder.state !== "configured") { + return; + } + + const nalType = nalData[0] & 0x1f; + const isIDR = nalType === NALU.IDR; + + try { + // Convert NAL unit to AVC format (add length prefix) + const avcData = this.convertNALToAVC(nalData); + + // Use performance.now() for better timing accuracy + const timestamp = performance.now() * 1000; // Convert to microseconds + + // Create EncodedVideoChunk + const chunk = new EncodedVideoChunk({ + type: isIDR ? "key" : "delta", + timestamp: timestamp, + data: avcData, + }); + + // Decode the chunk + this.decoder.decode(chunk); + } catch (error) { + console.error("[H264Client] Failed to decode frame:", error); + + // If decode fails, try to recreate decoder + if (this.decoder && this.decoder.state !== "configured") { + this.recreateDecoder(); + } + } + } + + // Convert NAL unit to AVC format (add length prefix) + private convertNALToAVC(nalUnit: Uint8Array): ArrayBuffer { + // Create 4-byte length prefix (big-endian) + const lengthPrefix = new Uint8Array(4); + const view = new DataView(lengthPrefix.buffer); + view.setUint32(0, nalUnit.length, false); // Big-endian + + // Combine length prefix + NAL unit data + const avcData = new Uint8Array(4 + nalUnit.length); + avcData.set(lengthPrefix, 0); + avcData.set(nalUnit, 4); + + return avcData.buffer; + } + + // Recreate decoder when it's closed + private recreateDecoder(): void { + // Close existing decoder if it exists + if (this.decoder && this.decoder.state === "configured") { + this.decoder.close(); + } + + // Create new decoder + this.decoder = new VideoDecoder({ + output: (frame) => this.onFrameDecoded(frame), + error: (error: DOMException) => { + console.error("[H264Client] VideoDecoder error:", error); + this.opts.onError?.(new Error(`VideoDecoder error: ${error.message}`)); + }, + }); + + // Reconfigure with existing SPS/PPS data + if (this.spsData && this.ppsData) { + this.tryConfigureDecoder(); + } + } + + // Handle decoded frame + private onFrameDecoded(frame: VideoFrame): void { + if (!this.context || !this.canvas) return; + + try { + // Update canvas size to match frame + if ( + this.canvas.width !== frame.displayWidth || + this.canvas.height !== frame.displayHeight + ) { + this.canvas.width = frame.displayWidth; + this.canvas.height = frame.displayHeight; + } + + // Draw frame to canvas + this.context.drawImage(frame, 0, 0); + } catch (error) { + console.error("[H264Client] Failed to render frame:", error); + } finally { + frame.close(); + } + } + + // Disconnect and cleanup + public disconnect(): void { + console.log("[H264Client] Disconnecting and cleaning up resources..."); + + // Notify disconnecting state + this.opts.onConnectionStateChange?.( + "disconnected", + "H.264 stream disconnected" + ); + + // Cancel HTTP request first + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + + // Close decoder + if (this.decoder) { + this.decoder.close(); + this.decoder = null; + } + + // Clear animation frame + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = undefined; + } + + // Close all pending frames + for (const { frame } of this.decodedFrames) { + frame.close(); + } + this.decodedFrames = []; + + // Clear canvas + if (this.canvas && this.canvas.parentNode) { + this.canvas.parentNode.removeChild(this.canvas); + this.canvas = null; + } + + this.context = null; + this.buffer = new Uint8Array(0); + this.spsData = null; + this.ppsData = null; + + console.log("[H264Client] Disconnect completed"); + } + + // Alias for disconnect (for compatibility) + public cleanup(): void { + this.disconnect(); + } +} diff --git a/packages/live-view/src/lib/mse-client.ts b/packages/live-view/src/lib/mse-client.ts new file mode 100644 index 00000000..f197f96f --- /dev/null +++ b/packages/live-view/src/lib/mse-client.ts @@ -0,0 +1,1159 @@ +import { ControlMessage } from "../types"; + +export class MSEClient { + private ws: WebSocket | null = null; + private currentDevice: string | null = null; + private isConnected: boolean = false; + public isMouseDragging: boolean = false; + private lastMouseTime: number = 0; + private videoElement: HTMLVideoElement | null = null; + + // Reconnection state + private isReconnecting: boolean = false; + private reconnectAttempts: number = 0; + private readonly maxReconnectAttempts: number = 3; // Reduced to 3 attempts for better UX + private reconnectTimer: number | null = null; + private lastConnectedDevice: string | null = null; + private lastBaseApiUrl: string | null = null; + private isManuallyDisconnected: boolean = false; + private shouldStopReconnecting: boolean = false; + + // Buffer management for smooth low latency + private bufferMonitorInterval: number | null = null; + private readonly MAX_BUFFER_DELAY = 0.3; // Maximum allowed buffer delay in seconds (balanced) + private readonly CATCHUP_THRESHOLD = 0.2; // Start catch-up when delay exceeds this threshold + private streamStartTime: number = 0; // Track when stream started for absolute delay calculation + private lastCatchupTime: number = 0; // Track last catch-up to prevent too frequent adjustments + + // Callbacks + private onConnectionStateChange?: ( + state: "connecting" | "connected" | "disconnected" | "error", + message?: string + ) => void; + private onError?: (error: Error) => void; + private onStatsUpdate?: (stats: any) => void; + + // Android key codes (same as WebRTC client) + static readonly ANDROID_KEYCODES = { + POWER: 26, + VOLUME_UP: 24, + VOLUME_DOWN: 25, + BACK: 4, + HOME: 3, + APP_SWITCH: 187, + MENU: 82, + }; + + constructor( + videoElement: HTMLVideoElement, + options: { + onConnectionStateChange?: ( + state: "connecting" | "connected" | "disconnected" | "error", + message?: string + ) => void; + onError?: (error: Error) => void; + onStatsUpdate?: (stats: any) => void; + } = {} + ) { + this.videoElement = videoElement; + this.onConnectionStateChange = options.onConnectionStateChange; + this.onError = options.onError; + this.onStatsUpdate = options.onStatsUpdate; + } + + async connect( + deviceSerial: string, + baseApiUrl: string = "/api" + ): Promise { + if (this.isConnected) { + console.log("[Streaming] Already connected"); + return; + } + + console.log("[Streaming] Starting connection to", deviceSerial); + this.isManuallyDisconnected = false; // Reset manual disconnect flag + this.shouldStopReconnecting = false; // Reset stop reconnecting flag + this.reconnectAttempts = 0; // Reset reconnection attempts + this.lastConnectedDevice = deviceSerial; // Store for reconnection + this.lastBaseApiUrl = baseApiUrl; // Store base API URL for reconnection + this.onConnectionStateChange?.("connecting", "Connecting to device..."); + + try { + // Step 1: Connect to streaming API + const response = await fetch( + `${baseApiUrl}/stream/${deviceSerial}/connect`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + mode: "stream", + config: { + video_codec: "h264", + audio_codec: "aac", + enable_audio: true, + }, + }), + } + ); + + if (!response.ok) { + let errorMessage = `Connection failed: ${response.status}`; + if (response.status === 404) { + errorMessage = "Device not found or not available"; + } else if (response.status === 500) { + errorMessage = "Server error, please try again"; + } else if (response.status === 503) { + errorMessage = "Service temporarily unavailable"; + } + throw new Error(errorMessage); + } + + const info = await response.json(); + console.log("[Streaming] Connected:", info); + + // Step 2: Set up video stream + this.currentDevice = deviceSerial; + this.lastConnectedDevice = deviceSerial; + + // Step 3: Set up control WebSocket first + await this.setupControlWebSocket(deviceSerial); + + if (this.videoElement) { + console.log( + "[Streaming] Video element available, scheduling video stream setup..." + ); + // Setup video stream immediately - no artificial delay + console.log("[Streaming] Setting up video stream immediately..."); + this.setupVideoStream(deviceSerial, baseApiUrl); + } else { + console.log( + "[Streaming] No video element available, skipping video stream setup" + ); + } + } catch (error) { + console.error("[Streaming] Connection failed:", error); + this.onConnectionStateChange?.("error", `Connection failed: ${error}`); + this.onError?.(error as Error); + + // Start reconnection if not manually disconnected + if (!this.isManuallyDisconnected && !this.shouldStopReconnecting) { + this.scheduleReconnect(deviceSerial); + } + + throw error; + } + } + + private async setupControlWebSocket(deviceSerial: string): Promise { + const wsUrl = `ws://${window.location.host}/api/stream/control/${deviceSerial}`; + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log("[Streaming] Control WebSocket connected"); + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + console.log("[Streaming] Control message:", message); + + if (message.type === "pong") { + // Handle pong responses + } + } catch (error) { + console.error("[Streaming] Failed to parse control message:", error); + } + }; + + this.ws.onerror = (error) => { + console.error("[Streaming] Control WebSocket error:", error); + // Don't trigger reconnection for WebSocket errors, let the main connection handle it + }; + + this.ws.onclose = () => { + console.log("[Streaming] Control WebSocket closed"); + // Only trigger reconnection if not manually disconnected + if ( + !this.isManuallyDisconnected && + !this.shouldStopReconnecting && + this.currentDevice + ) { + console.log( + "[Streaming] WebSocket closed unexpectedly, will attempt reconnection" + ); + this.scheduleReconnect(this.currentDevice); + } + }; + } + + private setupVideoStream( + deviceSerial: string, + baseApiUrl: string = "/api" + ): void { + console.log( + "[Streaming] setupVideoStream called with deviceSerial:", + deviceSerial, + "baseApiUrl:", + baseApiUrl + ); + + if (!this.videoElement) { + console.log( + "[Streaming] No video element in setupVideoStream, returning" + ); + return; + } + + console.log("[Streaming] Video element found, starting streaming modes..."); + + // Try different streaming modes for optimal latency + this.tryStreamingModes(deviceSerial, baseApiUrl).catch((error) => { + console.error("[Streaming] Error in streaming modes:", error); + this.onError?.(error); + + // Don't trigger reconnection for streaming mode failures + // Only reconnect for connection-level failures + console.log("[Streaming] Streaming mode failed, not reconnecting"); + }); + } + + private async tryStreamingModes( + deviceSerial: string, + baseApiUrl: string = "/api" + ): Promise { + console.log( + "[Streaming] tryStreamingModes called with deviceSerial:", + deviceSerial, + "baseApiUrl:", + baseApiUrl + ); + + if (!this.videoElement) { + console.log("[Streaming] No video element, streaming modes failed"); + return; + } + + // Use MSE (fMP4) for best quality and lowest latency + console.log("[Streaming] Attempting MSE stream..."); + if (await this.tryMSEStream(deviceSerial, baseApiUrl)) { + console.log("[Streaming] Using MSE for best quality and lowest latency"); + return; + } + + console.error("[Streaming] MSE streaming failed"); + + // Mode 3: Try fragmented MP4 (no keyframe wait) + if (this.tryFragmentedMP4(deviceSerial)) { + console.log("[Streaming] Using fragmented MP4 for low latency"); + return; + } + + // Mode 4: Fallback to regular MP4 + this.tryRegularMP4(deviceSerial); + } + + private cleanupMSE(): void { + // Stop buffer monitoring + this.stopBufferMonitoring(); + + if (this.videoElement) { + // Clean up MediaSource + if ((this.videoElement as any)._mediaSource) { + const mediaSource = (this.videoElement as any)._mediaSource; + if (mediaSource.readyState === "open") { + try { + mediaSource.endOfStream(); + } catch (e) { + // Ignore errors when ending stream + } + } + (this.videoElement as any)._mediaSource = null; + } + + // Clean up video element + if (this.videoElement.src) { + URL.revokeObjectURL(this.videoElement.src); + this.videoElement.src = ""; + } + } + } + + private async tryMSEStream( + deviceSerial: string, + baseApiUrl: string = "/api" + ): Promise { + console.log( + "[Streaming] tryMSEStream called with deviceSerial:", + deviceSerial, + "baseApiUrl:", + baseApiUrl + ); + + if (!this.videoElement) { + console.log("[Streaming] No video element, MSE stream failed"); + return false; + } + + // Check MSE support + if ( + !window.MediaSource || + !MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E"') + ) { + console.log("[Streaming] MSE not supported, trying other modes"); + return false; + } + + console.log("[Streaming] MSE is supported, proceeding with setup..."); + + try { + // Clean up any existing MediaSource completely + if (this.videoElement.src) { + URL.revokeObjectURL(this.videoElement.src); + this.videoElement.src = ""; + } + + // Wait a bit to ensure cleanup is complete + await new Promise((resolve) => setTimeout(resolve, 200)); + + const mediaSource = new MediaSource(); + this.videoElement.src = URL.createObjectURL(mediaSource); + + // Store MediaSource reference for cleanup + (this.videoElement as any)._mediaSource = mediaSource; + + mediaSource.addEventListener("sourceopen", async () => { + console.log("[Streaming] MSE source opened"); + // MediaSource is ready, proceed immediately + try { + console.log("[Streaming] Starting MSE stream setup..."); + await this.setupMSEStream(mediaSource, deviceSerial, baseApiUrl); + console.log("[Streaming] MSE stream setup completed successfully"); + } catch (error) { + console.error("[Streaming] MSE setup failed:", error); + this.cleanupMSE(); + this.onError?.(new Error("MSE setup failed")); + } + }); + + mediaSource.addEventListener("error", (e) => { + console.error("[Streaming] MSE MediaSource error:", e); + this.cleanupMSE(); + this.onError?.(new Error("MSE MediaSource error")); + }); + + this.videoElement.addEventListener("canplay", () => { + console.log("[Streaming] MSE video can play"); + this.isConnected = true; + this.onConnectionStateChange?.("connected", "Connected via MSE"); + // Don't set stream start time here, wait for actual play + // Start buffer monitoring for low latency + this.startBufferMonitoring(); + + // Gentle catch-up on first play + setTimeout(() => { + this.performImmediateCatchup(); + }, 500); + }); + + // Set stream start time when video actually starts playing + this.videoElement.addEventListener("play", () => { + this.streamStartTime = Date.now(); + console.log("[Streaming] Video started playing, set stream start time"); + }); + + this.videoElement.addEventListener("error", (e) => { + console.error("[Streaming] MSE video error:", e); + console.error("[Streaming] Video error details:", { + error: e, + target: e.target, + currentTarget: e.currentTarget, + type: e.type, + videoError: this.videoElement?.error + ? { + code: this.videoElement.error.code, + message: this.videoElement.error.message, + MEDIA_ERR_ABORTED: this.videoElement.error.MEDIA_ERR_ABORTED, + MEDIA_ERR_NETWORK: this.videoElement.error.MEDIA_ERR_NETWORK, + MEDIA_ERR_DECODE: this.videoElement.error.MEDIA_ERR_DECODE, + MEDIA_ERR_SRC_NOT_SUPPORTED: + this.videoElement.error.MEDIA_ERR_SRC_NOT_SUPPORTED, + } + : null, + videoNetworkState: this.videoElement?.networkState, + videoReadyState: this.videoElement?.readyState, + videoSrc: this.videoElement?.src, + videoCurrentTime: this.videoElement?.currentTime, + videoDuration: this.videoElement?.duration, + videoPaused: this.videoElement?.paused, + videoEnded: this.videoElement?.ended, + }); + + // Log individual error details for easier debugging + if (this.videoElement?.error) { + console.error( + "[Streaming] Video error code:", + this.videoElement.error.code + ); + console.error( + "[Streaming] Video error message:", + this.videoElement.error.message + ); + console.error( + "[Streaming] Video network state:", + this.videoElement.networkState + ); + console.error( + "[Streaming] Video ready state:", + this.videoElement.readyState + ); + } + this.cleanupMSE(); + this.onError?.(new Error("MSE video stream error")); + }); + + return true; + } catch (error) { + console.error("[Streaming] MSE setup failed:", error); + return false; + } + } + + private async setupMSEStream( + mediaSource: MediaSource, + deviceSerial: string, + baseApiUrl: string = "/api" + ): Promise { + try { + // Wait for MediaSource to be ready + if (mediaSource.readyState !== "open") { + console.log( + "[Streaming] Waiting for MediaSource to be ready...", + "state", + mediaSource.readyState + ); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("MediaSource ready timeout")); + }, 10000); // Increased timeout + + const checkReady = () => { + if (mediaSource.readyState === "open") { + clearTimeout(timeout); + resolve(undefined); + } else if (mediaSource.readyState === "closed") { + clearTimeout(timeout); + reject(new Error("MediaSource closed unexpectedly")); + } else { + setTimeout(checkReady, 100); // Increased interval + } + }; + checkReady(); + }); + } + + // Check if MediaSource already has source buffers and clear them + if (mediaSource.sourceBuffers.length > 0) { + console.log( + "[Streaming] MediaSource already has source buffers, clearing..." + ); + + // Wait for any ongoing updates to complete + const updatePromises = Array.from(mediaSource.sourceBuffers).map( + (sb) => { + if (sb.updating) { + return new Promise((resolve) => { + sb.addEventListener("updateend", () => resolve(), { + once: true, + }); + }); + } + return Promise.resolve(); + } + ); + + await Promise.all(updatePromises); + + // Remove all source buffers + try { + while (mediaSource.sourceBuffers.length > 0) { + const sourceBuffer = mediaSource.sourceBuffers[0]; + mediaSource.removeSourceBuffer(sourceBuffer); + } + } catch (error) { + console.error("[Streaming] Error clearing source buffers:", error); + // If we can't clear, create a new MediaSource + this.cleanupMSE(); + this.tryFragmentedMP4(deviceSerial); + return; + } + } + + let sourceBuffer; + try { + sourceBuffer = mediaSource.addSourceBuffer( + 'video/mp4; codecs="avc1.42E01E"' + ); + } catch (error) { + if (error instanceof Error && error.name === "QuotaExceededError") { + console.error( + "[Streaming] MSE SourceBuffer quota exceeded, falling back to Fragmented MP4" + ); + // Clean up MediaSource before fallback + this.cleanupMSE(); + // Fallback to Fragmented MP4 + this.tryFragmentedMP4(deviceSerial); + return; + } + throw error; + } + + // Fetch H.264 stream for MSE + console.log( + "[Streaming] Fetching H.264 stream from:", + `http://localhost:29888/api/stream/video/${deviceSerial}?mode=mse` + ); + + try { + const response = await fetch( + `http://localhost:29888/api/stream/video/${deviceSerial}?mode=mse` + ); + + console.log( + "[Streaming] MSE response status:", + response.status, + response.statusText + ); + console.log( + "[Streaming] MSE response headers:", + Object.fromEntries(response.headers.entries()) + ); + + if (!response.ok) { + throw new Error( + `MSE stream failed: ${response.status} ${response.statusText}` + ); + } + + const reader = response.body?.getReader(); + + if (!reader) { + throw new Error("No response body reader available"); + } + + console.log( + "[Streaming] H.264 stream reader created, starting to read data..." + ); + + let buffer = new Uint8Array(0); + let isFirstChunk = true; + let hasReceivedData = false; + let spsPpsSent = false; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + console.log("[Streaming] H.264 stream reader finished"); + break; + } + + console.log( + "[Streaming] Received H.264 data chunk:", + value.length, + "bytes" + ); + + // Append to buffer + const newBuffer = new Uint8Array(buffer.length + value.length); + newBuffer.set(buffer); + newBuffer.set(value, buffer.length); + buffer = newBuffer; + + // Wait for source buffer to be ready + while (sourceBuffer.updating) { + await new Promise((resolve) => { + sourceBuffer.addEventListener("updateend", resolve, { + once: true, + }); + }); + } + + try { + // Check if sourceBuffer is still valid and MediaSource is still open + if (!sourceBuffer) { + console.log("[Streaming] SourceBuffer invalid, stopping append"); + return; + } + + if (mediaSource.readyState !== "open") { + console.log( + "[Streaming] MediaSource not open, waiting...", + "state", + mediaSource.readyState + ); + // Wait a bit longer for MediaSource to be ready + await new Promise((resolve) => setTimeout(resolve, 200)); + // Check again after waiting + if ((mediaSource.readyState as string) !== "open") { + console.log( + "[Streaming] MediaSource still not open after waiting, skipping this chunk", + "state", + mediaSource.readyState + ); + return; + } + } + + // Process first chunk immediately + if (isFirstChunk) { + isFirstChunk = false; + } + + // Append MP4 fragment data to SourceBuffer + if (buffer.length > 0) { + console.log( + "[Streaming] Appending MP4 fragment to SourceBuffer:", + buffer.length, + "bytes" + ); + + try { + sourceBuffer.appendBuffer(buffer); + hasReceivedData = true; + console.log( + "[Streaming] Successfully appended MP4 fragment to SourceBuffer" + ); + buffer = new Uint8Array(0); // Clear buffer after successful append + } catch (e) { + console.error("[Streaming] Failed to append MP4 fragment:", e); + // Continue with next chunk + } + } + } catch (e) { + console.error("[Streaming] MSE append error:", e); + if (e instanceof Error && e.name === "InvalidStateError") { + if (e.message.includes("removed")) { + console.log( + "[Streaming] SourceBuffer removed, stopping append" + ); + return; + } + if (mediaSource.readyState === "closed") { + console.log("[Streaming] MediaSource closed, stopping append"); + return; + } + console.log("[Streaming] InvalidStateError, waiting...", e); + // Wait longer for InvalidStateError + await new Promise((resolve) => setTimeout(resolve, 200)); + } else if (e instanceof Error && e.name === "QuotaExceededError") { + console.error( + "[Streaming] SourceBuffer quota exceeded, falling back to Fragmented MP4" + ); + this.cleanupMSE(); + this.tryFragmentedMP4(deviceSerial); + return; + } else { + console.log("[Streaming] Buffer append failed, waiting...", e); + // Wait longer for other errors + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + } + + // Wait for final updates to complete + while (sourceBuffer.updating) { + await new Promise((resolve) => { + sourceBuffer.addEventListener("updateend", resolve, { once: true }); + }); + } + + // Only end stream if we received data and MediaSource is still open + if (hasReceivedData && mediaSource.readyState === "open") { + try { + mediaSource.endOfStream(); + } catch (e) { + console.warn("[Streaming] Failed to end MediaSource stream:", e); + } + } + } catch (fetchError) { + console.error("[Streaming] MSE fetch error:", fetchError); + throw fetchError; + } + } catch (error) { + console.error("[Streaming] MSE stream error:", error); + // Clean up MediaSource before fallback + this.cleanupMSE(); + // Try fallback to Fragmented MP4 + console.log( + "[Streaming] Falling back to Fragmented MP4 due to MSE error" + ); + this.tryFragmentedMP4(deviceSerial); + } + } + + private tryWebMStream(deviceSerial: string): boolean { + if (!this.videoElement) return false; + + try { + // Check if WebM is supported + if (!this.videoElement.canPlayType('video/webm; codecs="vp8"')) { + console.log("[Streaming] WebM not supported, trying other modes"); + return false; + } + + // Set up WebM stream with aggressive low-latency settings + this.videoElement.src = `/api/stream/video/${deviceSerial}?mode=webm`; + + // Set aggressive low-latency attributes + this.videoElement.preload = "none"; + this.videoElement.autoplay = true; + this.videoElement.muted = false; // Enable audio playback + this.videoElement.playsInline = true; + + // Set low-latency buffer settings (webkitVideoDecodedByteCount is read-only) + // Just ensure the video element is ready for low-latency playback + + this.videoElement.load(); + + this.videoElement.addEventListener("canplay", () => { + console.log("[Streaming] WebM can play"); + this.isConnected = true; + this.onConnectionStateChange?.( + "connected", + "Connected via WebM streaming" + ); + + // Start playing immediately for low latency + this.videoElement?.play().catch((e) => { + console.warn("[Streaming] WebM autoplay failed:", e); + }); + }); + + this.videoElement.addEventListener("error", (e) => { + console.error("[Streaming] WebM error:", e); + this.onError?.(new Error("WebM stream error")); + }); + + return true; + } catch (error) { + console.error("[Streaming] WebM setup failed:", error); + return false; + } + } + + private tryFragmentedMP4(deviceSerial: string): boolean { + if (!this.videoElement) return false; + + try { + // Set up fragmented MP4 stream + this.videoElement.src = `/api/stream/video/${deviceSerial}?mode=fmp4`; + this.videoElement.load(); + + this.videoElement.addEventListener("canplay", () => { + console.log("[Streaming] Fragmented MP4 can play"); + this.isConnected = true; + this.onConnectionStateChange?.( + "connected", + "Connected via Fragmented MP4" + ); + }); + + this.videoElement.addEventListener("error", (e) => { + console.error("[Streaming] Fragmented MP4 error:", e); + this.onError?.(new Error("Fragmented MP4 stream error")); + }); + + return true; + } catch (error) { + console.error("[Streaming] Fragmented MP4 setup failed:", error); + return false; + } + } + + private tryRegularMP4(deviceSerial: string): void { + if (!this.videoElement) return; + + // Set up regular MP4 stream (fallback) + this.videoElement.src = `/api/stream/video/${deviceSerial}?mode=ffmpeg`; + this.videoElement.load(); + + this.videoElement.addEventListener("loadstart", () => { + console.log("[Streaming] Regular MP4 stream started"); + }); + + this.videoElement.addEventListener("error", (e) => { + console.error("[Streaming] Regular MP4 stream error:", e); + this.onError?.(new Error("Regular MP4 stream error")); + }); + + this.videoElement.addEventListener("canplay", () => { + console.log("[Streaming] Regular MP4 can play"); + this.isConnected = true; + this.onConnectionStateChange?.("connected", "Connected via Regular MP4"); + + // Update stats + this.onStatsUpdate?.({ + resolution: `${this.videoElement!.videoWidth}x${ + this.videoElement!.videoHeight + }`, + fps: 0, // TODO: Calculate FPS + latency: 0, // TODO: Calculate latency + }); + }); + + this.videoElement.addEventListener("loadedmetadata", () => { + console.log("[Streaming] Regular MP4 metadata loaded"); + // Set connected state when metadata is available + this.isConnected = true; + this.onConnectionStateChange?.("connected", "Connected via Regular MP4"); + }); + } + + async disconnect(): Promise { + if (!this.isConnected || !this.currentDevice) { + return; + } + + console.log("[Streaming] Disconnecting from", this.currentDevice); + this.isManuallyDisconnected = true; // Mark as manually disconnected + this.shouldStopReconnecting = true; // Stop any ongoing reconnection attempts + this.onConnectionStateChange?.("disconnected", "Disconnecting..."); + + try { + // Stop video stream and clean up MediaSource + if (this.videoElement) { + // Clean up MediaSource if it exists + if ( + this.videoElement.src && + this.videoElement.src.startsWith("blob:") + ) { + URL.revokeObjectURL(this.videoElement.src); + } + this.videoElement.src = ""; + this.videoElement.load(); + } + + // Close control WebSocket + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + // Call disconnect API + await fetch(`/api/stream/${this.currentDevice}/disconnect`, { + method: "POST", + }); + + this.currentDevice = null; + this.isConnected = false; + this.onConnectionStateChange?.("disconnected", "Disconnected"); + } catch (error) { + console.error("[Streaming] Disconnect failed:", error); + this.onError?.(error as Error); + } + } + + sendControlMessage(message: ControlMessage | any): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.warn("[Streaming] Control WebSocket not ready"); + return; + } + + try { + this.ws.send(JSON.stringify(message)); + } catch (error) { + console.error("[Streaming] Failed to send control message:", error); + } + } + + // Touch event methods (similar to WebRTC client) + sendTouchEvent( + action: string, + x: number, + y: number, + pressure: number = 1.0, + pointerId: number = 0 + ): void { + // Ignore touches outside video area + if (x < 0 || x > 1 || y < 0 || y > 1) { + console.log( + `[Streaming] Touch outside video area ignored: x=${x.toFixed( + 3 + )}, y=${y.toFixed(3)}` + ); + return; + } + + const message: ControlMessage = { + type: "touch", + action, + x, + y, + pressure, + pointerId, + }; + + this.sendControlMessage(message); + } + + sendKeyEvent(keycode: number, action: string, metaState: number = 0): void { + const message: ControlMessage = { + type: "key", + action, + keycode, + metaState, + }; + + this.sendControlMessage(message); + } + + sendScrollEvent( + x: number, + y: number, + hScroll: number, + vScroll: number + ): void { + const message: ControlMessage = { + type: "scroll", + x, + y, + hScroll, + vScroll, + }; + + this.sendControlMessage(message); + } + + requestKeyframe(): void { + // Temporarily disabled to prevent Video capture reset + console.log( + "[Streaming] requestKeyframe called but disabled to prevent Video capture reset" + ); + return; + + const message: ControlMessage = { + type: "reset_video", + }; + + this.sendControlMessage(message); + } + + sendClipboardText(text: string, paste: boolean = false): void { + const message: ControlMessage = { + type: "clipboard_set", + text, + paste, + }; + + this.sendControlMessage(message); + } + + // Mouse event handlers (compatible with useMouseHandler) + handleMouseEvent(event: MouseEvent, action: string): void { + if (!this.videoElement || !this.isConnected) return; + + const rect = this.videoElement.getBoundingClientRect(); + const x = (event.clientX - rect.left) / rect.width; + const y = (event.clientY - rect.top) / rect.height; + + // Convert mouse action to touch action + const touchAction = + action === "down" ? "down" : action === "up" ? "up" : "move"; + + this.sendTouchEvent(touchAction, x, y, 1.0, 0); + + // Update dragging state + if (action === "down") { + this.isMouseDragging = true; + } else if (action === "up") { + this.isMouseDragging = false; + } + } + + handleTouchEvent(event: TouchEvent, action: string): void { + if (!this.videoElement || !this.isConnected) return; + + const rect = this.videoElement.getBoundingClientRect(); + const touch = event.touches[0] || event.changedTouches[0]; + + if (!touch) return; + + const x = (touch.clientX - rect.left) / rect.width; + const y = (touch.clientY - rect.top) / rect.height; + const pressure = touch.force || 1.0; + const pointerId = touch.identifier || 0; + + this.sendTouchEvent(action, x, y, pressure, pointerId); + } + + private scheduleReconnect(deviceSerial: string): void { + if ( + this.isReconnecting || + this.shouldStopReconnecting || + this.isManuallyDisconnected + ) { + return; + } + + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.log( + "[Streaming] Max reconnection attempts reached, stopping reconnection" + ); + this.onConnectionStateChange?.( + "error", + "Max reconnection attempts reached" + ); + this.shouldStopReconnecting = true; + return; + } + + this.isReconnecting = true; + this.reconnectAttempts++; + + // Exponential backoff with jitter to avoid thundering herd + const baseDelay = 1000 * Math.pow(2, this.reconnectAttempts - 1); + const jitter = Math.random() * 1000; // Add up to 1 second of jitter + const delay = Math.min(baseDelay + jitter, 30000); // Max 30 seconds + + console.log( + `[Streaming] Scheduling reconnection attempt ${this.reconnectAttempts}/${ + this.maxReconnectAttempts + } in ${Math.round(delay)}ms` + ); + + this.reconnectTimer = window.setTimeout(async () => { + if (this.shouldStopReconnecting || this.isManuallyDisconnected) { + this.isReconnecting = false; + return; + } + + try { + console.log( + `[Streaming] Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts}` + ); + this.onConnectionStateChange?.( + "connecting", + `Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})` + ); + + await this.connect(deviceSerial, this.lastBaseApiUrl || "/api"); + this.reconnectAttempts = 0; // Reset on successful connection + this.isReconnecting = false; + } catch (error) { + console.error( + `[Streaming] Reconnection attempt ${this.reconnectAttempts} failed:`, + error + ); + this.isReconnecting = false; + + // Check if this is a 404 error (device not found) and stop retrying + if (error instanceof Error && error.message.includes("404")) { + console.log( + "[Streaming] Device not found (404), stopping reconnection attempts" + ); + this.shouldStopReconnecting = true; + this.onConnectionStateChange?.( + "error", + "Device not found or not available" + ); + return; + } + + // Schedule next attempt only if we haven't exceeded max attempts + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.scheduleReconnect(deviceSerial); + } + } + }, delay); + } + + private startBufferMonitoring(): void { + if (this.bufferMonitorInterval) { + clearInterval(this.bufferMonitorInterval); + } + + this.bufferMonitorInterval = window.setInterval(() => { + this.monitorBufferDelay(); + }, 200); // Check every 200ms for smooth catch-up + } + + private stopBufferMonitoring(): void { + if (this.bufferMonitorInterval) { + clearInterval(this.bufferMonitorInterval); + this.bufferMonitorInterval = null; + } + } + + private performImmediateCatchup(): void { + if (!this.videoElement || this.videoElement.paused) { + return; + } + + try { + const buffered = this.videoElement.buffered; + if (buffered.length > 0) { + const bufferedEnd = buffered.end(buffered.length - 1); + const currentTime = this.videoElement.currentTime; + const delay = bufferedEnd - currentTime; + + if (delay > 0.05) { + // If there's any delay at all + console.log( + `[Streaming] Performing immediate catch-up: ${delay.toFixed( + 2 + )}s delay, jumping to ${bufferedEnd.toFixed(2)}s` + ); + this.videoElement.currentTime = bufferedEnd - 0.01; // Jump to very end + } + } + } catch (error) { + console.warn("[Streaming] Error performing immediate catch-up:", error); + } + } + + private monitorBufferDelay(): void { + if (!this.videoElement || this.videoElement.paused) { + return; + } + + try { + const buffered = this.videoElement.buffered; + if (buffered.length > 0) { + const bufferedEnd = buffered.end(buffered.length - 1); + const delay = bufferedEnd - this.videoElement.currentTime; + + // Aggressive catch-up is the most reliable way to reduce latency + if (delay > 0.2) { + // If buffer is more than 200ms + console.log( + `[Streaming] High buffer delay (${delay.toFixed( + 2 + )}s). Catching up...` + ); + this.videoElement.currentTime = bufferedEnd - 0.01; // Jump to the end + } + + // Keep playback rate constant for a smoother experience + this.videoElement.playbackRate = 1.0; + } + } catch (error) { + console.warn("[Streaming] Error monitoring buffer delay:", error); + } + } + + cleanup(): void { + this.shouldStopReconnecting = true; + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + // Stop buffer monitoring + this.stopBufferMonitoring(); + + this.disconnect(); + } +} diff --git a/packages/live-view/src/lib/webrtc-client.ts b/packages/live-view/src/lib/webrtc-client.ts index 8cc47ac1..1bb72341 100644 --- a/packages/live-view/src/lib/webrtc-client.ts +++ b/packages/live-view/src/lib/webrtc-client.ts @@ -12,6 +12,13 @@ export class WebRTCClient { private videoElement: HTMLVideoElement | null = null; private audioElement: HTMLAudioElement | null = null; + // Control message queue for early messages before DataChannel is ready + private pendingControlMessages: ControlMessage[] = []; + + // Video reset throttling to prevent server overload + private lastKeyframeRequest: number = 0; + private readonly KEYFRAME_THROTTLE_MS = 2000; // Minimum 2 seconds between keyframe requests + // Reconnection state private isReconnecting: boolean = false; private reconnectAttempts: number = 0; @@ -71,11 +78,11 @@ export class WebRTCClient { this.lastConnectedDevice = deviceSerial; this.isReconnecting = false; this.reconnectAttempts = 0; - this.onConnectionStateChange?.("connecting", "正在连接设备..."); + this.onConnectionStateChange?.("connecting", "Connecting to device..."); try { console.log("[WebRTC] Starting WebRTC connection establishment"); - await this.establishWebRTCConnection(deviceSerial, wsUrl); + await this.establishWebRTCConnection(deviceSerial, wsUrl, false); } catch (error) { console.error("[WebRTC] Connection failed:", error); @@ -90,24 +97,27 @@ export class WebRTCClient { ); this.onConnectionStateChange?.( "disconnected", - "连接已关闭,正在重连..." + "Connection closed, reconnecting..." ); return; // Don't throw error, let automatic reconnection handle it } this.onError?.(error as Error); - this.onConnectionStateChange?.("error", "连接失败"); + this.onConnectionStateChange?.("error", "Connection failed"); throw error; } } private async establishWebRTCConnection( deviceSerial: string, - wsUrl: string + wsUrl: string, + isReconnection: boolean = false ): Promise { - const fullWsUrl = `${wsUrl}?device=${deviceSerial}`; - console.log(`[WebRTC] Creating WebSocket connection to: ${fullWsUrl}`); - this.ws = new WebSocket(fullWsUrl); + // Use /api/stream/control/ endpoint for WebRTC signaling instead of generic /ws + const baseUrl = wsUrl.replace(/\/ws$/, ''); // Remove /ws suffix if present + const controlWsUrl = `${baseUrl}/api/stream/control/${deviceSerial}`.replace(/^http/, 'ws'); + console.log(`[WebRTC] Creating WebSocket connection to: ${controlWsUrl}`); + this.ws = new WebSocket(controlWsUrl); // Create WebRTC peer connection with balanced low-latency settings this.pc = new RTCPeerConnection({ @@ -133,6 +143,10 @@ export class WebRTCClient { direction: "recvonly", }); + console.log("[WebRTC] Created transceivers - Video mid:", videoTransceiver.mid, "Audio mid:", audioTransceiver.mid); + console.log("[WebRTC] Video transceiver direction:", videoTransceiver.direction); + console.log("[WebRTC] Audio transceiver direction:", audioTransceiver.direction); + // Set reasonable low latency hints (not ultra-aggressive) if ("playoutDelayHint" in videoTransceiver.receiver) { (videoTransceiver.receiver as any).playoutDelayHint = 0.1; // 100ms instead of 0 @@ -160,8 +174,14 @@ export class WebRTCClient { console.log("[WebRTC] WebSocket connected, creating offer"); try { - // Create and send offer - const offer = await this.pc!.createOffer(); + // Create and send offer with ICE restart if this is a reconnection + const offerOptions: RTCOfferOptions = {}; + if (isReconnection) { + console.log("[WebRTC] Adding ICE restart to offer for reconnection"); + offerOptions.iceRestart = true; + } + const offer = await this.pc!.createOffer(offerOptions); + console.log("[WebRTC] Offer SDP preview:", offer.sdp?.substring(0, 200) + "..."); await this.pc!.setLocalDescription(offer); // Send offer with deviceSerial and proper structure @@ -243,14 +263,22 @@ export class WebRTCClient { }; this.pc.onicecandidate = (event) => { - if (event.candidate && this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send( - JSON.stringify({ - type: "ice-candidate", - deviceSerial: this.currentDevice, - candidate: event.candidate, - }) - ); + if (event.candidate) { + console.log("[WebRTC] ICE candidate generated:", event.candidate.candidate.substring(0, 50) + "..."); + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send( + JSON.stringify({ + type: "ice-candidate", + deviceSerial: this.currentDevice, + candidate: event.candidate, + }) + ); + console.log("[WebRTC] ICE candidate sent to server"); + } else { + console.warn("[WebRTC] Cannot send ICE candidate - WebSocket not ready", this.ws?.readyState); + } + } else { + console.log("[WebRTC] ICE candidate gathering finished"); } }; @@ -266,6 +294,25 @@ export class WebRTCClient { } }; + this.pc.oniceconnectionstatechange = () => { + if (!this.pc) return; + console.log("[WebRTC] ICE Connection state changed:", this.pc.iceConnectionState); + + // Handle ICE connection failures + if (this.pc.iceConnectionState === "failed") { + console.log("[WebRTC] ICE connection failed - attempting restart"); + // Try to restart ICE + this.pc.restartIce(); + } else if (this.pc.iceConnectionState === "disconnected") { + console.log("[WebRTC] ICE connection disconnected"); + } + }; + + this.pc.onicegatheringstatechange = () => { + if (!this.pc) return; + console.log("[WebRTC] ICE Gathering state:", this.pc.iceGatheringState); + }; + this.pc.onconnectionstatechange = () => { if (!this.pc) return; console.log("[WebRTC] Connection state:", this.pc.connectionState); @@ -321,7 +368,7 @@ export class WebRTCClient { this.audioElement.play().catch((e) => { console.error("Audio playback failed:", e); - this.onError?.(new Error("音频播放失败,点击页面启用音频")); + this.onError?.(new Error("Audio playback failed, click page to enable audio")); }); } @@ -379,6 +426,9 @@ export class WebRTCClient { // Handle both formats: direct sdp or nested in answer object const sdp = message.sdp || (message as any).answer?.sdp; + if (sdp) { + console.log("[WebRTC] Answer SDP preview:", sdp.substring(0, 200) + "..."); + } if (!sdp) { console.error( "[WebRTC] Answer missing SDP field, full message:", @@ -398,7 +448,13 @@ export class WebRTCClient { case "ice-candidate": if (message.candidate) { console.log("[WebRTC] Adding ICE candidate"); - await this.pc.addIceCandidate(new RTCIceCandidate(message.candidate)); + try { + await this.pc.addIceCandidate(new RTCIceCandidate(message.candidate)); + console.log("[WebRTC] ICE candidate added successfully"); + } catch (error) { + console.error("[WebRTC] Failed to add ICE candidate:", error); + console.log("[WebRTC] Candidate that failed:", message.candidate); + } } break; @@ -443,10 +499,10 @@ export class WebRTCClient { "Connection error, reconnecting..." ); - // Wait a bit before reconnecting to allow server to clean up + // Wait longer before reconnecting to allow server to clean up setTimeout(() => { this.startReconnection(); - }, 500); + }, 1000); } // Don't trigger error callback for recoverable errors @@ -473,6 +529,20 @@ export class WebRTCClient { this.dataChannel.onopen = () => { console.log("[WebRTC] Data channel opened"); + + // Process any pending control messages (filter out reset_video to avoid duplicates) + if (this.pendingControlMessages.length > 0) { + const filteredMessages = this.pendingControlMessages.filter(msg => msg.type !== "reset_video"); + if (filteredMessages.length > 0) { + console.log(`[WebRTC] Processing ${filteredMessages.length} pending control messages`); + filteredMessages.forEach(message => { + this.sendControlMessageDirect(message); + }); + } + this.pendingControlMessages = []; + } + + // Request keyframe only once after DataChannel is ready setTimeout(() => this.requestKeyframe(), 500); }; @@ -564,15 +634,23 @@ export class WebRTCClient { sendControlMessage(message: ControlMessage): void { if (!this.dataChannel) { - console.warn("[WebRTC] Data channel not available"); + // Queue message for when DataChannel becomes available + this.pendingControlMessages.push(message); return; } if (this.dataChannel.readyState !== "open") { - console.warn( - "[WebRTC] Data channel not open, state:", - this.dataChannel.readyState - ); + // Queue message for when DataChannel opens + this.pendingControlMessages.push(message); + return; + } + + // Send message directly + this.sendControlMessageDirect(message); + } + + private sendControlMessageDirect(message: ControlMessage): void { + if (!this.dataChannel || this.dataChannel.readyState !== "open") { return; } @@ -598,18 +676,22 @@ export class WebRTCClient { console.log("[WebRTC] Sending control message:", msgWithTimestamp); } - // Handle clipboard messages with binary data specially - if (typeof message.type === "number" && message.data) { - // For clipboard messages, send as binary data - const binaryMessage = { - type: message.type, - data: Array.from(message.data), // Convert Uint8Array to regular array for JSON - timestamp: Date.now(), - }; - this.dataChannel.send(JSON.stringify(binaryMessage)); - } else { - // For regular messages, send as JSON - this.dataChannel.send(JSON.stringify(msgWithTimestamp)); + try { + // Handle clipboard messages with binary data specially + if (typeof message.type === "number" && message.data) { + // For clipboard messages, send as binary data + const binaryMessage = { + type: message.type, + data: Array.from(message.data), // Convert Uint8Array to regular array for JSON + timestamp: Date.now(), + }; + this.dataChannel.send(JSON.stringify(binaryMessage)); + } else { + // For regular messages, send as JSON + this.dataChannel.send(JSON.stringify(msgWithTimestamp)); + } + } catch (error) { + console.error("[WebRTC] Failed to send control message:", error); } } @@ -871,6 +953,16 @@ export class WebRTCClient { } requestKeyframe(): void { + const now = Date.now(); + + // Throttle keyframe requests to prevent server overload + if (now - this.lastKeyframeRequest < this.KEYFRAME_THROTTLE_MS) { + console.log(`[WebRTC] Keyframe request throttled (last: ${now - this.lastKeyframeRequest}ms ago)`); + return; + } + + this.lastKeyframeRequest = now; + console.log("[WebRTC] Requesting keyframe"); this.sendControlMessage({ type: "reset_video" }); } @@ -1004,8 +1096,8 @@ export class WebRTCClient { if (!this.isReconnecting || !this.lastConnectedDevice) return; this.reconnectAttempts++; - // Use shorter delays for faster reconnection: 1s, 2s, 3s, 5s, then 5s repeatedly - const delays = [1000, 2000, 3000, 5000]; + // Use longer delays that give backend time to cleanup ICE connections: 3s, 5s, 7s, 10s, then 10s repeatedly + const delays = [3000, 5000, 7000, 10000]; const delay = delays[Math.min(this.reconnectAttempts - 1, delays.length - 1)]; @@ -1026,15 +1118,22 @@ export class WebRTCClient { // Actually attempt to reconnect try { - // Extract base WebSocket URL - const baseUrl = this.ws?.url?.split("?")[0] || "ws://localhost:29888/ws"; + // Extract base URL from current WebSocket URL (remove device-specific parts) + let baseUrl = "ws://localhost:29888"; + if (this.ws?.url) { + // Remove /api/stream/control/{device} to get base URL + baseUrl = this.ws.url.replace(/\/api\/stream\/control\/[^\/]+$/, ''); + } console.log( `[WebRTC] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}` ); - // Try to reconnect - await this.connect(this.lastConnectedDevice, baseUrl); + // Set up state for reconnection + this.currentDevice = this.lastConnectedDevice; + + // Try to reconnect with ICE restart enabled + await this.establishWebRTCConnection(this.lastConnectedDevice, `${baseUrl}/ws`, true); // If successful, reset counters this.isReconnecting = false; @@ -1073,30 +1172,55 @@ export class WebRTCClient { this.stopStallDetection(); this.stopPingMeasurement(); - // Close data channel + // Clear pending control messages and reset throttling + this.pendingControlMessages = []; + this.lastKeyframeRequest = 0; + + // Close data channel with more aggressive cleanup if (this.dataChannel) { try { - this.dataChannel.close(); + if (this.dataChannel.readyState === "open" || this.dataChannel.readyState === "connecting") { + this.dataChannel.close(); + } } catch (e) { console.warn("[WebRTC] Error closing data channel:", e); } this.dataChannel = null; } - // Close peer connection + // Close peer connection with more aggressive cleanup if (this.pc) { try { + // Force close all transceivers first + this.pc.getTransceivers().forEach(transceiver => { + try { + transceiver.stop(); + } catch (e) { + console.warn("[WebRTC] Error stopping transceiver:", e); + } + }); + + // Close the peer connection this.pc.close(); + + // Wait a bit for cleanup + await new Promise(resolve => setTimeout(resolve, 100)); } catch (e) { console.warn("[WebRTC] Error closing peer connection:", e); } this.pc = null; } - // Close WebSocket + // Close WebSocket gracefully if (this.ws) { try { - this.ws.close(); + if (this.ws.readyState === WebSocket.OPEN) { + // Send close frame with normal closure code + this.ws.close(1000, "Client disconnecting"); + } else if (this.ws.readyState === WebSocket.CONNECTING) { + // Force close if still connecting + this.ws.close(); + } } catch (e) { console.warn("[WebRTC] Error closing WebSocket:", e); } diff --git a/packages/live-view/src/main.tsx b/packages/live-view/src/main.tsx index 456e91fa..5b7c78f3 100644 --- a/packages/live-view/src/main.tsx +++ b/packages/live-view/src/main.tsx @@ -7,12 +7,14 @@ import './main.css'; const params = new URLSearchParams(window.location.search); const apiUrl = params.get('api') || import.meta.env.VITE_API_URL || '/api'; const wsUrl = params.get('ws') || import.meta.env.VITE_WS_URL || `ws://${window.location.host}/ws`; +const mode = params.get('mode') || 'h264'; // Default to H.264 mode ReactDOM.createRoot(document.getElementById('root')!).render( Date: Sat, 20 Sep 2025 00:14:34 +0800 Subject: [PATCH 05/34] feat: enhance audio streaming and connection handling - Added support for new audio codec options in H.264 streaming mode. - Improved logging for audio stream processing and connection handling. - Refactored device connection logic to provide better error handling and debugging information. - Updated live view components to reflect changes in streaming modes and improve user experience. - Removed unused MSE client code to streamline the codebase. --- packages/cli/go.mod | 5 +- packages/cli/go.sum | 2 + .../device_connect/control/clipboard.go | 61 + .../device_connect/control/control.go | 116 ++ .../internal/device_connect/control/key.go | 67 + .../internal/device_connect/control/scroll.go | 69 + .../internal/device_connect/control/touch.go | 86 ++ .../device_connect/device/connection.go | 37 +- .../device_connect/pipeline/pipeline.go | 8 +- .../internal/device_connect/scrcpy/source.go | 14 +- .../transport/audio/streaming.go | 256 ++++ .../device_connect/transport/audio/webm.go | 243 +++ .../cli/internal/server/handlers/streaming.go | 1325 +---------------- .../server/handlers/streaming_test.go | 334 +++++ .../server/handlers/streaming_utils.go | 31 + .../cli/internal/server/handlers/webrtc.go | 22 +- .../cli/internal/server/router/streaming.go | 7 +- packages/live-view/index.html | 1 + .../src/components/AndroidLiveView.module.css | 5 + .../src/components/AndroidLiveView.tsx | 137 +- .../src/components/ControlButtons.tsx | 23 +- .../live-view/src/components/DeviceList.tsx | 20 +- packages/live-view/src/lib/h264-client.ts | 483 +++++- packages/live-view/src/lib/mse-client.ts | 1159 -------------- packages/live-view/src/types.ts | 2 +- 25 files changed, 1964 insertions(+), 2549 deletions(-) create mode 100644 packages/cli/internal/device_connect/control/clipboard.go create mode 100644 packages/cli/internal/device_connect/control/control.go create mode 100644 packages/cli/internal/device_connect/control/key.go create mode 100644 packages/cli/internal/device_connect/control/scroll.go create mode 100644 packages/cli/internal/device_connect/control/touch.go create mode 100644 packages/cli/internal/device_connect/transport/audio/streaming.go create mode 100644 packages/cli/internal/device_connect/transport/audio/webm.go create mode 100644 packages/cli/internal/server/handlers/streaming_test.go create mode 100644 packages/cli/internal/server/handlers/streaming_utils.go delete mode 100644 packages/live-view/src/lib/mse-client.ts diff --git a/packages/cli/go.mod b/packages/cli/go.mod index 878059db..972ed672 100644 --- a/packages/cli/go.mod +++ b/packages/cli/go.mod @@ -4,7 +4,9 @@ go 1.23.7 require ( github.com/adrg/xdg v0.5.3 + github.com/at-wat/ebml-go v0.17.1 github.com/babelcloud/gbox-sdk-go v0.1.0-alpha.3 + github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 github.com/pion/webrtc/v4 v4.1.4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -18,7 +20,7 @@ require ( require ( github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect @@ -32,7 +34,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/mux v1.8.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pion/datachannel v1.5.10 // indirect diff --git a/packages/cli/go.sum b/packages/cli/go.sum index 28b6d7ea..b7226d90 100644 --- a/packages/cli/go.sum +++ b/packages/cli/go.sum @@ -1,5 +1,7 @@ github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/at-wat/ebml-go v0.17.1 h1:pWG1NOATCFu1hnlowCzrA1VR/3s8tPY6qpU+2FwW7X4= +github.com/at-wat/ebml-go v0.17.1/go.mod h1:w1cJs7zmGsb5nnSvhWGKLCxvfu4FVx5ERvYDIalj1ww= github.com/babelcloud/gbox-sdk-go v0.1.0-alpha.3 h1:4cpajFHLDSAZvlQsYWOKeszZNDFzpLKP2vk37GncbDI= github.com/babelcloud/gbox-sdk-go v0.1.0-alpha.3/go.mod h1:Su+RubcjR8UAHVd/Wxf2roq6Y0kZCxhXHBLrA4+Dnl0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/packages/cli/internal/device_connect/control/clipboard.go b/packages/cli/internal/device_connect/control/clipboard.go new file mode 100644 index 00000000..d8312850 --- /dev/null +++ b/packages/cli/internal/device_connect/control/clipboard.go @@ -0,0 +1,61 @@ +package control + +import ( + "log" +) + +// ClipboardHandler handles clipboard control events +type ClipboardHandler struct { + controlService *ControlService +} + +// NewClipboardHandler creates a new clipboard handler +func NewClipboardHandler(controlService *ControlService) *ClipboardHandler { + return &ClipboardHandler{ + controlService: controlService, + } +} + +// ProcessClipboardEvent processes a clipboard event +func (h *ClipboardHandler) ProcessClipboardEvent(msg map[string]interface{}, deviceSerial string) error { + text, _ := msg["text"].(string) + paste, _ := msg["paste"].(bool) + + log.Printf("Clipboard event: device=%s, text_length=%d, paste=%t", + deviceSerial, len(text), paste) + + // TODO: Implement clipboard event processing logic + // This could include: + // - Text validation + // - Clipboard state management + // - Event queuing + // - Bridge communication + + return nil +} + +// ValidateClipboardEvent validates clipboard event data +func (h *ClipboardHandler) ValidateClipboardEvent(msg map[string]interface{}) error { + // Validate required fields + if _, ok := msg["text"].(string); !ok { + return ErrMissingText + } + if _, ok := msg["paste"].(bool); !ok { + return ErrMissingPaste + } + + // Validate text length + text, _ := msg["text"].(string) + if len(text) > 10000 { // Reasonable limit + return ErrTextTooLong + } + + return nil +} + +// Error definitions +var ( + ErrMissingText = &ControlError{Code: "MISSING_TEXT", Message: "Missing text field"} + ErrMissingPaste = &ControlError{Code: "MISSING_PASTE", Message: "Missing paste field"} + ErrTextTooLong = &ControlError{Code: "TEXT_TOO_LONG", Message: "Text too long"} +) diff --git a/packages/cli/internal/device_connect/control/control.go b/packages/cli/internal/device_connect/control/control.go new file mode 100644 index 00000000..61dc11d7 --- /dev/null +++ b/packages/cli/internal/device_connect/control/control.go @@ -0,0 +1,116 @@ +package control + +import ( + "log" +) + +// ControlService 控制服务 +type ControlService struct { + // 控制相关的依赖 +} + +// NewControlService creates a new control service +func NewControlService() *ControlService { + return &ControlService{} +} + +// HandleTouchEvent 处理触摸事件 +func (s *ControlService) HandleTouchEvent(msg map[string]interface{}, deviceSerial string) error { + action, _ := msg["action"].(string) + x, _ := msg["x"].(float64) + y, _ := msg["y"].(float64) + pressure, _ := msg["pressure"].(float64) + pointerId, _ := msg["pointerId"].(float64) + + log.Printf("Touch event: device=%s, action=%s, x=%.3f, y=%.3f, pressure=%.2f, pointerId=%.0f", + deviceSerial, action, x, y, pressure, pointerId) + + // TODO: Forward touch event to bridge manager + // This will be implemented when bridge integration is ready + log.Printf("Touch event received but bridge integration not yet implemented") + + return nil +} + +// HandleKeyEvent 处理键盘事件 +func (s *ControlService) HandleKeyEvent(msg map[string]interface{}, deviceSerial string) error { + action, _ := msg["action"].(string) + keycode, _ := msg["keycode"].(float64) + metaState, _ := msg["metaState"].(float64) + + log.Printf("Key event: device=%s, action=%s, keycode=%.0f, metaState=%.0f", + deviceSerial, action, keycode, metaState) + + // TODO: Forward key event to bridge manager + // This will be implemented when bridge integration is ready + log.Printf("Key event received but bridge integration not yet implemented") + + return nil +} + +// HandleScrollEvent 处理滚动事件 +func (s *ControlService) HandleScrollEvent(msg map[string]interface{}, deviceSerial string) error { + x, _ := msg["x"].(float64) + y, _ := msg["y"].(float64) + hScroll, _ := msg["hScroll"].(float64) + vScroll, _ := msg["vScroll"].(float64) + + log.Printf("Scroll event: device=%s, x=%.3f, y=%.3f, hScroll=%.2f, vScroll=%.2f", + deviceSerial, x, y, hScroll, vScroll) + + // TODO: Forward scroll event to bridge manager + // This will be implemented when bridge integration is ready + log.Printf("Scroll event received but bridge integration not yet implemented") + + return nil +} + +// HandleClipboardEvent 处理剪贴板事件 +func (s *ControlService) HandleClipboardEvent(msg map[string]interface{}, deviceSerial string) error { + text, _ := msg["text"].(string) + paste, _ := msg["paste"].(bool) + + log.Printf("Clipboard event: device=%s, text_length=%d, paste=%t", + deviceSerial, len(text), paste) + + // TODO: Forward clipboard event to bridge manager + // This will be implemented when bridge integration is ready + log.Printf("Clipboard event received but bridge integration not yet implemented") + + return nil +} + +// HandleVideoResetEvent 处理视频重置事件 +func (s *ControlService) HandleVideoResetEvent(msg map[string]interface{}, deviceSerial string) error { + log.Printf("Reset video event: device=%s", deviceSerial) + + // TODO: Forward video reset event to bridge manager + // This will be implemented when bridge integration is ready + log.Printf("Video reset event received but bridge integration not yet implemented") + + return nil +} + +// HandleWebRTCEvent 处理 WebRTC 事件 +func (s *ControlService) HandleWebRTCEvent(msg map[string]interface{}, deviceSerial string) error { + msgType, _ := msg["type"].(string) + + log.Printf("WebRTC event: device=%s, type=%s", deviceSerial, msgType) + + // TODO: Forward WebRTC event to WebRTC handler + // This will be implemented when WebRTC integration is ready + log.Printf("WebRTC event received but WebRTC integration not yet implemented") + + return nil +} + +// 全局控制服务实例 +var controlService *ControlService + +// GetControlService 获取控制服务实例 +func GetControlService() *ControlService { + if controlService == nil { + controlService = NewControlService() + } + return controlService +} diff --git a/packages/cli/internal/device_connect/control/key.go b/packages/cli/internal/device_connect/control/key.go new file mode 100644 index 00000000..61d3d286 --- /dev/null +++ b/packages/cli/internal/device_connect/control/key.go @@ -0,0 +1,67 @@ +package control + +import ( + "log" +) + +// KeyHandler handles keyboard control events +type KeyHandler struct { + controlService *ControlService +} + +// NewKeyHandler creates a new key handler +func NewKeyHandler(controlService *ControlService) *KeyHandler { + return &KeyHandler{ + controlService: controlService, + } +} + +// ProcessKeyEvent processes a key event +func (h *KeyHandler) ProcessKeyEvent(msg map[string]interface{}, deviceSerial string) error { + action, _ := msg["action"].(string) + keycode, _ := msg["keycode"].(float64) + metaState, _ := msg["metaState"].(float64) + + log.Printf("Key event: device=%s, action=%s, keycode=%.0f, metaState=%.0f", + deviceSerial, action, keycode, metaState) + + // TODO: Implement key event processing logic + // This could include: + // - Key code validation + // - Meta state processing + // - Event queuing + // - Bridge communication + + return nil +} + +// ValidateKeyEvent validates key event data +func (h *KeyHandler) ValidateKeyEvent(msg map[string]interface{}) error { + // Validate required fields + if _, ok := msg["action"].(string); !ok { + return ErrMissingAction + } + if _, ok := msg["keycode"].(float64); !ok { + return ErrMissingKeycode + } + + // Validate action type + action, _ := msg["action"].(string) + if action != "down" && action != "up" { + return ErrInvalidAction + } + + // Validate keycode + keycode, _ := msg["keycode"].(float64) + if keycode < 0 || keycode > 255 { + return ErrInvalidKeycode + } + + return nil +} + +// Error definitions +var ( + ErrMissingKeycode = &ControlError{Code: "MISSING_KEYCODE", Message: "Missing keycode field"} + ErrInvalidKeycode = &ControlError{Code: "INVALID_KEYCODE", Message: "Invalid keycode value"} +) diff --git a/packages/cli/internal/device_connect/control/scroll.go b/packages/cli/internal/device_connect/control/scroll.go new file mode 100644 index 00000000..6725a163 --- /dev/null +++ b/packages/cli/internal/device_connect/control/scroll.go @@ -0,0 +1,69 @@ +package control + +import ( + "log" +) + +// ScrollHandler handles scroll control events +type ScrollHandler struct { + controlService *ControlService +} + +// NewScrollHandler creates a new scroll handler +func NewScrollHandler(controlService *ControlService) *ScrollHandler { + return &ScrollHandler{ + controlService: controlService, + } +} + +// ProcessScrollEvent processes a scroll event +func (h *ScrollHandler) ProcessScrollEvent(msg map[string]interface{}, deviceSerial string) error { + x, _ := msg["x"].(float64) + y, _ := msg["y"].(float64) + hScroll, _ := msg["hScroll"].(float64) + vScroll, _ := msg["vScroll"].(float64) + + log.Printf("Scroll event: device=%s, x=%.3f, y=%.3f, hScroll=%.2f, vScroll=%.2f", + deviceSerial, x, y, hScroll, vScroll) + + // TODO: Implement scroll event processing logic + // This could include: + // - Scroll amount validation + // - Coordinate transformation + // - Event queuing + // - Bridge communication + + return nil +} + +// ValidateScrollEvent validates scroll event data +func (h *ScrollHandler) ValidateScrollEvent(msg map[string]interface{}) error { + // Validate required fields + if _, ok := msg["x"].(float64); !ok { + return ErrMissingX + } + if _, ok := msg["y"].(float64); !ok { + return ErrMissingY + } + if _, ok := msg["hScroll"].(float64); !ok { + return ErrMissingHScroll + } + if _, ok := msg["vScroll"].(float64); !ok { + return ErrMissingVScroll + } + + // Validate coordinates + x, _ := msg["x"].(float64) + y, _ := msg["y"].(float64) + if x < 0 || y < 0 { + return ErrInvalidCoordinates + } + + return nil +} + +// Error definitions +var ( + ErrMissingHScroll = &ControlError{Code: "MISSING_HSCROLL", Message: "Missing hScroll field"} + ErrMissingVScroll = &ControlError{Code: "MISSING_VSCROLL", Message: "Missing vScroll field"} +) diff --git a/packages/cli/internal/device_connect/control/touch.go b/packages/cli/internal/device_connect/control/touch.go new file mode 100644 index 00000000..55924199 --- /dev/null +++ b/packages/cli/internal/device_connect/control/touch.go @@ -0,0 +1,86 @@ +package control + +import ( + "log" +) + +// TouchHandler handles touch control events +type TouchHandler struct { + controlService *ControlService +} + +// NewTouchHandler creates a new touch handler +func NewTouchHandler(controlService *ControlService) *TouchHandler { + return &TouchHandler{ + controlService: controlService, + } +} + +// ProcessTouchEvent processes a touch event +func (h *TouchHandler) ProcessTouchEvent(msg map[string]interface{}, deviceSerial string) error { + action, _ := msg["action"].(string) + x, _ := msg["x"].(float64) + y, _ := msg["y"].(float64) + pressure, _ := msg["pressure"].(float64) + pointerId, _ := msg["pointerId"].(float64) + + log.Printf("Touch event: device=%s, action=%s, x=%.3f, y=%.3f, pressure=%.2f, pointerId=%.0f", + deviceSerial, action, x, y, pressure, pointerId) + + // TODO: Implement touch event processing logic + // This could include: + // - Input validation + // - Coordinate transformation + // - Event queuing + // - Bridge communication + + return nil +} + +// ValidateTouchEvent validates touch event data +func (h *TouchHandler) ValidateTouchEvent(msg map[string]interface{}) error { + // Validate required fields + if _, ok := msg["action"].(string); !ok { + return ErrMissingAction + } + if _, ok := msg["x"].(float64); !ok { + return ErrMissingX + } + if _, ok := msg["y"].(float64); !ok { + return ErrMissingY + } + + // Validate action type + action, _ := msg["action"].(string) + if action != "down" && action != "up" && action != "move" { + return ErrInvalidAction + } + + // Validate coordinates + x, _ := msg["x"].(float64) + y, _ := msg["y"].(float64) + if x < 0 || y < 0 { + return ErrInvalidCoordinates + } + + return nil +} + +// Error definitions +var ( + ErrMissingAction = &ControlError{Code: "MISSING_ACTION", Message: "Missing action field"} + ErrMissingX = &ControlError{Code: "MISSING_X", Message: "Missing x coordinate"} + ErrMissingY = &ControlError{Code: "MISSING_Y", Message: "Missing y coordinate"} + ErrInvalidAction = &ControlError{Code: "INVALID_ACTION", Message: "Invalid action type"} + ErrInvalidCoordinates = &ControlError{Code: "INVALID_COORDINATES", Message: "Invalid coordinates"} +) + +// ControlError represents a control-related error +type ControlError struct { + Code string + Message string +} + +func (e *ControlError) Error() string { + return e.Message +} diff --git a/packages/cli/internal/device_connect/device/connection.go b/packages/cli/internal/device_connect/device/connection.go index 63802f63..ef157cb0 100644 --- a/packages/cli/internal/device_connect/device/connection.go +++ b/packages/cli/internal/device_connect/device/connection.go @@ -124,20 +124,41 @@ func (sc *ScrcpyConnection) Connect() (net.Conn, error) { } // 5. Accept connection from scrcpy server - log.Printf("Waiting for scrcpy server to connect...") + log.Printf("Waiting for scrcpy server to connect on port %d...", sc.scid) - // Set deadline for accept - if err := listener.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + // Set deadline for accept (extend timeout to 20 seconds for hardware encoder) + timeout := 20 * time.Second + deadline := time.Now().Add(timeout) + if err := listener.(*net.TCPListener).SetDeadline(deadline); err != nil { listener.Close() return nil, fmt.Errorf("failed to set deadline: %w", err) } + log.Printf("Listening for scrcpy server connection with %v timeout...", timeout) + conn, err := listener.Accept() if err != nil { listener.Close() if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + log.Printf("Timeout waiting for scrcpy server connection on port %d", sc.scid) + log.Printf("Debug: Check if adb reverse port forward is working...") + + // Debug: Check reverse port forward status + checkCmd := exec.Command(sc.adbPath, "-s", sc.deviceSerial, "reverse", "--list") + if output, err := checkCmd.Output(); err == nil { + log.Printf("Debug: Current reverse port forwards:\n%s", string(output)) + } + + // Debug: Check if scrcpy server process is running + psCmd := exec.Command(sc.adbPath, "-s", sc.deviceSerial, "shell", "ps | grep scrcpy") + if output, err := psCmd.Output(); err == nil && len(output) > 0 { + log.Printf("Debug: Scrcpy server processes found:\n%s", string(output)) + } else { + log.Printf("Debug: No scrcpy server processes found - server may have crashed") + } + sc.killScrcpyServer() - return nil, fmt.Errorf("timeout waiting for scrcpy server") + return nil, fmt.Errorf("timeout waiting for scrcpy server after %v", timeout) } return nil, fmt.Errorf("failed to accept connection: %w", err) } @@ -210,15 +231,17 @@ func (sc *ScrcpyConnection) startScrcpyServer() error { "3.3.1", // Server version - must match the downloaded jar fmt.Sprintf("scid=%s", scidHex), "video=true", - "audio=true", // Re-enable audio + "audio=true", "control=true", "cleanup=true", "log_level=verbose", // Enable verbose logging to debug scroll issues "video_codec_options=i-frame-interval=1", } - // Use default video encoder for all modes - log.Printf("Using default video encoder for %s mode", sc.streamingMode) + // Add hardware video encoder if device supports it + // Use hardware encoder (c2.qti.avc.encoder) for better performance on Qualcomm devices + args = append(args, "video_encoder=c2.qti.avc.encoder") + log.Printf("Using hardware video encoder (c2.qti.avc.encoder) for %s mode", sc.streamingMode) cmd := exec.Command(sc.adbPath, args...) diff --git a/packages/cli/internal/device_connect/pipeline/pipeline.go b/packages/cli/internal/device_connect/pipeline/pipeline.go index 902f83da..e13675d4 100644 --- a/packages/cli/internal/device_connect/pipeline/pipeline.go +++ b/packages/cli/internal/device_connect/pipeline/pipeline.go @@ -109,12 +109,18 @@ func (p *Pipeline) PublishAudio(sample core.AudioSample) { p.mu.RLock() defer p.mu.RUnlock() + if len(p.audioSubs) == 0 { + util.GetLogger().Debug("🎵 No audio subscribers, dropping sample", "size", len(sample.Data)) + return + } + for id, ch := range p.audioSubs { select { case ch <- sample: + util.GetLogger().Debug("🎵 Audio sample sent to subscriber", "subscriber", id, "size", len(sample.Data)) default: // Channel is full, skip - util.GetLogger().Debug("Audio channel full, dropping sample", "subscriber", id) + util.GetLogger().Warn("🎵 Audio channel full, dropping sample", "subscriber", id) } } } diff --git a/packages/cli/internal/device_connect/scrcpy/source.go b/packages/cli/internal/device_connect/scrcpy/source.go index df33be2a..908c08be 100644 --- a/packages/cli/internal/device_connect/scrcpy/source.go +++ b/packages/cli/internal/device_connect/scrcpy/source.go @@ -271,7 +271,7 @@ func (s *Source) handleStreamConnection(ctx context.Context, conn net.Conn) { if s.audioConn == nil { // This is the audio stream (2nd connection) - logger.Debug("Audio stream connected", "device", s.deviceSerial) + logger.Info("🎵 Audio stream connected", "device", s.deviceSerial) s.audioConn = conn go s.handleAudioStream(ctx, conn) } else if s.controlConn == nil { @@ -355,17 +355,18 @@ func (s *Source) handleVideoStream(ctx context.Context, conn net.Conn) { // handleAudioStream processes the audio stream func (s *Source) handleAudioStream(ctx context.Context, conn net.Conn) { logger := util.GetLogger() - logger.Debug("Starting audio stream processing", "device", s.deviceSerial) + logger.Info("🎵 Starting audio stream processing", "device", s.deviceSerial) defer func() { conn.Close() - logger.Info("Audio stream processing stopped", "device", s.deviceSerial) + logger.Info("🎵 Audio stream processing stopped", "device", s.deviceSerial) }() // Read audio metadata if err := s.readAudioMetadata(conn); err != nil { - logger.Error("Failed to read audio metadata", "device", s.deviceSerial, "error", err) + logger.Error("❌ Failed to read audio metadata", "device", s.deviceSerial, "error", err) return } + logger.Info("✅ Audio metadata read successfully", "device", s.deviceSerial) // Start reading audio packets for { @@ -403,7 +404,10 @@ func (s *Source) handleAudioStream(ctx context.Context, conn net.Conn) { PTS: int64(packet.PTS), } - // Audio packet processed (debug logging removed to reduce noise) + // Log first few audio packets + if len(sample.Data) > 0 { + logger.Debug("🎵 Audio packet received", "device", s.deviceSerial, "size", len(sample.Data), "pts", sample.PTS) + } // Publish to pipeline s.pipeline.PublishAudio(sample) diff --git a/packages/cli/internal/device_connect/transport/audio/streaming.go b/packages/cli/internal/device_connect/transport/audio/streaming.go new file mode 100644 index 00000000..bc684ca9 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/audio/streaming.go @@ -0,0 +1,256 @@ +package audio + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" +) + +// AudioStreamingService 音频流服务 +type AudioStreamingService struct { + source core.Source +} + +// NewAudioStreamingService creates a new audio streaming service +func NewAudioStreamingService() *AudioStreamingService { + return &AudioStreamingService{} +} + +// SetSource sets the audio source +func (s *AudioStreamingService) SetSource(source core.Source) { + s.source = source +} + +// StreamOpus 流式处理 Opus 音频 - 只支持WebM格式 +func (s *AudioStreamingService) StreamOpus(deviceSerial string, writer io.Writer, format string) error { + logger := slog.With("device", deviceSerial, "format", format) + logger.Info("🎵 Starting Opus audio stream", "format", format) + + // Only support WebM format + if format != "webm" { + logger.Error("❌ Unsupported format", "format", format) + return fmt.Errorf("unsupported format: %s. Only 'webm' is supported", format) + } + + // Get audio stream from device source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + logger.Error("❌ Device source not found - is scrcpy running for this device?") + return fmt.Errorf("device not connected") + } + + logger.Info("✅ Found scrcpy source for Opus streaming") + + // Subscribe to audio stream + subscriberID := fmt.Sprintf("audio_opus_%p", writer) + audioCh := source.SubscribeAudio(subscriberID, 100) + defer source.UnsubscribeAudio(subscriberID) + + logger.Info("🎵 Subscribed to Opus stream", "subscriberID", subscriberID) + + // Create professional WebM muxer + muxer := NewProfessionalWebMMuxer(writer) + defer muxer.Close() + + // Write WebM header + if err := muxer.WriteHeader(); err != nil { + logger.Error("❌ Failed to write WebM header", "error", err) + return err + } + logger.Info("✅ WebM container initialized") + + sampleCount := 0 + startTime := time.Now() + for sample := range audioCh { + sampleCount++ + + // Skip empty samples + if len(sample.Data) == 0 { + continue + } + + // Write frame using professional WebM muxer with comprehensive error recovery + timestamp := time.Since(startTime) + + // Add comprehensive panic and error protection + func() { + defer func() { + if r := recover(); r != nil { + logger.Warn("🎵 WebM write panic recovered at streaming level", "panic", r, "frame", sampleCount) + // Mark muxer as failed to prevent further writes + muxer = nil + } + }() + + if muxer != nil { + if writeErr := muxer.WriteOpusFrame(sample.Data, timestamp); writeErr != nil { + if writeErr == io.ErrClosedPipe { + logger.Info("🎵 Client disconnected, stopping audio stream", "frames_sent", sampleCount) + } else { + logger.Error("❌ Failed to write WebM frame", "error", writeErr, "frame", sampleCount) + } + } + } + }() + + // If muxer was set to nil due to panic, stop streaming + if muxer == nil { + logger.Info("🎵 Muxer failed due to panic, stopping stream", "frames_sent", sampleCount) + return nil + } + + // Log successful transmission for first few frames + if sampleCount <= 5 { + logger.Info("✅ Successfully sent WebM Opus data", "count", sampleCount, "size", len(sample.Data)) + } + } + + return nil +} + +// StreamWebMForMSE streams Opus audio as WebM optimized for MSE consumption +func (s *AudioStreamingService) StreamWebMForMSE(deviceSerial string, w http.ResponseWriter, r *http.Request) error { + logger := slog.With("component", "mse_streaming", "device", deviceSerial) + logger.Info("🎵 Starting MSE-optimized WebM audio stream") + + // Set HTTP headers for MSE streaming + 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") + + // Start streaming immediately + w.WriteHeader(http.StatusOK) + + // Ensure we can flush chunks + flusher, ok := w.(http.Flusher) + if !ok { + return fmt.Errorf("streaming not supported") + } + flusher.Flush() + + // Get audio stream from device source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + return fmt.Errorf("device source not found: %s", deviceSerial) + } + + // Subscribe to audio stream with larger buffer for MSE stability + subscriberID := fmt.Sprintf("mse_webm_%s_%d", deviceSerial, time.Now().UnixNano()) + audioCh := source.SubscribeAudio(subscriberID, 1000) + defer source.UnsubscribeAudio(subscriberID) + + logger.Info("🎵 Subscribed to audio stream", "subscriberID", subscriberID) + + // Create professional WebM muxer + muxer := NewProfessionalWebMMuxer(w) + defer muxer.Close() + + // Write WebM header immediately for MSE initialization + if err := muxer.WriteHeader(); err != nil { + logger.Error("Failed to write WebM header", "error", err) + return err + } + flusher.Flush() + + logger.Info("✅ WebM header sent, starting audio data stream") + + startTime := time.Now() + frameCount := 0 + var lastFlushTime time.Time + + // Stream audio frames + for { + select { + case sample, ok := <-audioCh: + if !ok { + logger.Info("🎵 Audio channel closed") + return nil + } + + // Skip empty samples + if len(sample.Data) == 0 { + continue + } + + // Calculate relative timestamp + timestamp := time.Since(startTime) + + // Write Opus frame to WebM stream with comprehensive protection + writeSuccess := false + func() { + defer func() { + if r := recover(); r != nil { + logger.Warn("🎵 MSE WebM write panic recovered", "panic", r, "frame", frameCount) + // Mark muxer as failed to prevent further writes + muxer = nil + } + }() + + if muxer != nil { + if writeErr := muxer.WriteOpusFrame(sample.Data, timestamp); writeErr != nil { + // Check if this is a client disconnect (expected) + if writeErr == io.ErrClosedPipe { + logger.Info("🎵 Client disconnected during MSE streaming", "frames_sent", frameCount) + } else { + logger.Error("Failed to write Opus frame", "error", writeErr) + } + } else { + writeSuccess = true + } + } + }() + + // If muxer was set to nil due to panic, stop streaming + if muxer == nil { + logger.Info("🎵 MSE muxer failed due to panic, stopping stream", "frames_sent", frameCount) + return nil + } + + // If write failed, return error + if !writeSuccess { + return io.ErrClosedPipe // Treat as normal termination + } + + frameCount++ + + // Force flush every 100ms for low latency (MSE optimization) + now := time.Now() + if now.Sub(lastFlushTime) >= 100*time.Millisecond { + flusher.Flush() + lastFlushTime = now + + // Log progress every 5 seconds + if frameCount%250 == 0 { // ~5s at 20ms frames + stats := muxer.GetStats() + logger.Info("🎵 MSE WebM streaming progress", + "frames", frameCount, + "duration", timestamp.Truncate(time.Millisecond), + "stats", stats) + } + } + + case <-r.Context().Done(): + logger.Info("🎵 Client disconnected", "frames_sent", frameCount) + return nil + } + } +} + +// 全局音频流服务实例 +var audioService *AudioStreamingService + +// GetAudioService 获取音频流服务实例 +func GetAudioService() *AudioStreamingService { + if audioService == nil { + audioService = NewAudioStreamingService() + } + return audioService +} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/transport/audio/webm.go b/packages/cli/internal/device_connect/transport/audio/webm.go new file mode 100644 index 00000000..af448684 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/audio/webm.go @@ -0,0 +1,243 @@ +package audio + +import ( + "fmt" + "io" + "log/slog" + "time" + + "github.com/at-wat/ebml-go/webm" +) + +// ProfessionalWebMMuxer provides professional WebM container using at-wat/ebml-go +// Based on the official Pion WebRTC save-to-webm example +type ProfessionalWebMMuxer struct { + writer io.Writer + audioWriter webm.BlockWriteCloser + logger *slog.Logger + initialized bool + frameCount uint64 + audioTimestamp time.Duration +} + +// NewProfessionalWebMMuxer creates a new professional WebM muxer +func NewProfessionalWebMMuxer(writer io.Writer) *ProfessionalWebMMuxer { + return &ProfessionalWebMMuxer{ + writer: writer, + logger: slog.With("component", "professional_webm_muxer"), + } +} + +// safeWriterCloser wraps an io.Writer with comprehensive panic recovery +type safeWriterCloser struct { + writer io.Writer + logger *slog.Logger + closed bool +} + +func (swc *safeWriterCloser) Write(p []byte) (n int, err error) { + // Double-check closed state + if swc.closed { + return 0, io.ErrClosedPipe + } + + // Additional safety check - verify writer is still valid + if swc.writer == nil { + swc.closed = true + return 0, io.ErrClosedPipe + } + + // Comprehensive panic recovery + defer func() { + if r := recover(); r != nil { + swc.logger.Warn("Write operation panic recovered", "panic", r) + swc.closed = true + err = io.ErrClosedPipe + n = 0 + } + }() + + // Additional safety check before write + if swc.closed { + return 0, io.ErrClosedPipe + } + + n, err = swc.writer.Write(p) + if err != nil { + swc.logger.Warn("Write error detected, marking writer as closed", "error", err) + swc.closed = true + } + return n, err +} + +func (swc *safeWriterCloser) Close() error { + swc.closed = true + return nil // No-op close +} + +// WriteHeader initializes the WebM container with audio track +func (m *ProfessionalWebMMuxer) WriteHeader() error { + if m.initialized { + return nil + } + + m.logger.Info("🎵 Initializing professional WebM container based on Pion example") + + // Comprehensive panic recovery for the entire initialization + var initErr error + func() { + defer func() { + if r := recover(); r != nil { + m.logger.Error("WebM initialization panic recovered", "panic", r) + initErr = fmt.Errorf("WebM initialization failed due to panic: %v", r) + } + }() + + // Wrap writer with comprehensive panic recovery + writeCloser := &safeWriterCloser{ + writer: m.writer, + logger: m.logger, + closed: false, + } + + // Create WebM writer with audio track configuration (matching Pion's example) + writers, err := webm.NewSimpleBlockWriter(writeCloser, []webm.TrackEntry{ + { + Name: "Audio", + TrackNumber: 1, + TrackUID: 12345, + CodecID: "A_OPUS", + TrackType: 2, // Audio track type + DefaultDuration: 20000000, // 20ms in nanoseconds (typical Opus frame duration) + Audio: &webm.Audio{ + SamplingFrequency: 48000.0, // 48kHz + Channels: 2, // Stereo + }, + }, + }) + + if err != nil { + m.logger.Error("Failed to create WebM writer", "error", err) + initErr = err + return + } + + // Get the audio writer from the slice + m.audioWriter = writers[0] + m.initialized = true + }() + + if initErr != nil { + return initErr + } + + m.logger.Info("✅ Professional WebM container initialized successfully") + return nil +} + +// WriteOpusFrame writes an Opus frame to the WebM container +func (m *ProfessionalWebMMuxer) WriteOpusFrame(opusData []byte, timestamp time.Duration) error { + // Early validation checks + if opusData == nil || len(opusData) == 0 { + return nil // Skip empty frames + } + + if !m.initialized { + if err := m.WriteHeader(); err != nil { + return err + } + } + + // Check if audioWriter is still valid (in case of stream closure) + if m.audioWriter == nil { + m.logger.Warn("WebM writer is closed, cannot write frame") + return io.ErrClosedPipe + } + + // Additional safety check for muxer state + defer func() { + if r := recover(); r != nil { + m.logger.Warn("WriteOpusFrame panic recovered", "panic", r, "frame", m.frameCount) + // Mark writer as closed to prevent further writes + m.audioWriter = nil + } + }() + + // Update audio timestamp (cumulative duration) + // Using fixed 20ms duration for Opus frames (typical) + frameTimestamp := 20 * time.Millisecond + m.audioTimestamp += frameTimestamp + + // Safely write to WebM container with panic recovery + var err error + func() { + defer func() { + if r := recover(); r != nil { + m.logger.Warn("WebM write panic recovered", "panic", r, "frame", m.frameCount) + err = io.ErrClosedPipe + // Mark writer as closed to prevent further writes + m.audioWriter = nil + } + }() + + // Write to WebM container + // Parameters: isKeyframe (true for audio), timestamp in milliseconds, data + _, err = m.audioWriter.Write(true, int64(m.audioTimestamp/time.Millisecond), opusData) + }() + + if err != nil { + if err == io.ErrClosedPipe { + m.logger.Info("Audio stream closed, stopping WebM stream", "frame", m.frameCount) + } else { + m.logger.Error("Failed to write Opus frame to WebM", "error", err, "frame", m.frameCount) + } + return err + } + + m.frameCount++ + + // Log progress every 250 frames (~5 seconds at 20ms per frame) + if m.frameCount%250 == 0 { + m.logger.Debug("🎵 WebM audio progress", + "frames", m.frameCount, + "duration", m.audioTimestamp.Truncate(time.Millisecond), + "data_size", len(opusData)) + } + + return nil +} + +// Close finalizes the WebM container +func (m *ProfessionalWebMMuxer) Close() error { + if m.audioWriter != nil { + m.logger.Info("🎵 Finalizing WebM container", "total_frames", m.frameCount) + + // Safe close with panic recovery + func() { + defer func() { + if r := recover(); r != nil { + m.logger.Warn("WebM close panic recovered", "panic", r) + } + }() + + if err := m.audioWriter.Close(); err != nil { + m.logger.Warn("WebM writer close error (expected if stream ended)", "error", err) + } + }() + + m.audioWriter = nil + } + + m.logger.Info("✅ Professional WebM muxer closed successfully") + return nil +} + +// GetStats returns muxer statistics +func (m *ProfessionalWebMMuxer) GetStats() map[string]interface{} { + return map[string]interface{}{ + "frames_written": m.frameCount, + "audio_duration_ms": int64(m.audioTimestamp / time.Millisecond), + "initialized": m.initialized, + "type": "professional_webm", + } +} \ No newline at end of file diff --git a/packages/cli/internal/server/handlers/streaming.go b/packages/cli/internal/server/handlers/streaming.go index d4d77964..61cc7617 100644 --- a/packages/cli/internal/server/handlers/streaming.go +++ b/packages/cli/internal/server/handlers/streaming.go @@ -3,23 +3,16 @@ package handlers import ( "fmt" "log" - "log/slog" "net/http" - "strconv" "strings" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" + "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/mse" "github.com/gorilla/websocket" ) -var controlUpgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true // Allow all origins for now - }, -} - // StreamingHandlers contains handlers for streaming routes type StreamingHandlers struct { // We'll pass necessary dependencies when needed @@ -91,28 +84,6 @@ func (h *StreamingHandlers) HandleVideoStream(w http.ResponseWriter, r *http.Req } } -// HandleVideoWebSocket handles WebSocket video streaming (consolidated endpoint) -func (h *StreamingHandlers) HandleVideoWebSocket(w http.ResponseWriter, r *http.Request) { - // Extract device serial from path /stream/video/ws/{device} - path := strings.TrimPrefix(r.URL.Path, "/stream/video/ws/") - parts := strings.Split(path, "?") - deviceSerial := parts[0] - - if deviceSerial == "" { - http.Error(w, "Device serial required", http.StatusBadRequest) - return - } - - if !isValidDeviceSerial(deviceSerial) { - http.Error(w, "Invalid device serial", http.StatusBadRequest) - return - } - - // Use H.264 WebSocket handler - handler := h264.NewWSHandler(deviceSerial) - handler.ServeWebSocket(w, r) -} - // HandleAudioStream handles audio streaming endpoints func (h *StreamingHandlers) HandleAudioStream(w http.ResponseWriter, r *http.Request) { // Extract device serial from path @@ -136,18 +107,44 @@ func (h *StreamingHandlers) HandleAudioStream(w http.ResponseWriter, r *http.Req codec = "aac" // Default to AAC } - switch codec { - case "aac": - // AAC codec (default - placeholder) - h.handleOpusAudioHTTP(w, r, deviceSerial) + // Parse format parameter + format := r.URL.Query().Get("format") - case "opus": - // Opus codec - h.handleOpusAudioHTTP(w, r, deviceSerial) + // Check for MSE-optimized WebM streaming + mseOptimized := r.URL.Query().Get("mse") == "true" - default: - http.Error(w, "Invalid codec. Supported: aac, opus", http.StatusBadRequest) + // Handle MSE-optimized WebM streaming (new approach) + if codec == "opus" && format == "webm" && mseOptimized { + audioService := audio.GetAudioService() + if err := audioService.StreamWebMForMSE(deviceSerial, w, r); err != nil { + log.Printf("MSE WebM streaming error: %v", err) + http.Error(w, "MSE streaming failed", http.StatusInternalServerError) + } + return } + + // Only support Opus codec with WebM format + if codec != "opus" { + http.Error(w, "Invalid codec. Only 'opus' is supported", http.StatusBadRequest) + return + } + + // Set WebM/Opus content type (best browser compatibility) + w.Header().Set("Content-Type", "audio/webm; codecs=opus") + + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Write headers immediately to start the stream + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + // Stream Opus audio with WebM container (forced for consistency) + audioService := audio.GetAudioService() + audioService.StreamOpus(deviceSerial, w, "webm") } // HandleStreamInfo provides information about available streams @@ -164,18 +161,16 @@ func (h *StreamingHandlers) HandleStreamInfo(w http.ResponseWriter, r *http.Requ "device": deviceSerial, "supportedModes": []string{"h264", "mse", "webrtc"}, "supportedFormats": map[string][]string{ - "h264": {"annexb", "avc"}, - "mse": {"fmp4"}, + "h264": {"annexb", "avc"}, + "mse": {"fmp4"}, + "audio": {"opus"}, }, "endpoints": map[string]string{ "video": h.buildURL(fmt.Sprintf("/stream/video/%s", deviceSerial)), "video_h264": h.buildURL(fmt.Sprintf("/stream/video/%s?mode=h264", deviceSerial)), "video_h264_avc": h.buildURL(fmt.Sprintf("/stream/video/%s?mode=h264&format=avc", deviceSerial)), "video_mse": h.buildURL(fmt.Sprintf("/stream/video/%s?mode=mse", deviceSerial)), - "video_ws": h.buildURL(fmt.Sprintf("/stream/video/ws/%s", deviceSerial)), - "audio": h.buildURL(fmt.Sprintf("/stream/audio/%s", deviceSerial)), - "audio_aac": h.buildURL(fmt.Sprintf("/stream/audio/%s?codec=aac", deviceSerial)), - "audio_opus": h.buildURL(fmt.Sprintf("/stream/audio/%s?codec=opus", deviceSerial)), + "audio": h.buildURL(fmt.Sprintf("/stream/audio/%s?codec=opus", deviceSerial)), "control": h.buildURL(fmt.Sprintf("/stream/control/%s", deviceSerial)), "webrtc": "/webrtc/signaling", // WebRTC uses signaling endpoint }, @@ -276,6 +271,9 @@ func (h *StreamingHandlers) HandleControlWebSocket(w http.ResponseWriter, r *htt log.Printf("Control WebSocket connection established for device: %s", deviceSerial) + // Delegate to control service + controlService := control.GetControlService() + // Handle WebSocket messages for { var msg map[string]interface{} @@ -299,27 +297,23 @@ func (h *StreamingHandlers) HandleControlWebSocket(w http.ResponseWriter, r *htt switch msgType { // WebRTC signaling messages - delegate to WebRTC handler case "ping", "offer", "answer", "ice-candidate": - if h.webrtcHandlers != nil { - h.delegateToWebRTCHandler(conn, msg, msgType, deviceSerial) - } else { - log.Printf("WebRTC handlers not initialized") - } + h.delegateToWebRTCHandler(conn, msg, msgType, deviceSerial) // Device control messages case "touch": - h.handleTouchMessage(conn, msg, deviceSerial) + controlService.HandleTouchEvent(msg, deviceSerial) case "key": - h.handleKeyMessage(conn, msg, deviceSerial) + controlService.HandleKeyEvent(msg, deviceSerial) case "scroll": - h.handleScrollMessage(conn, msg, deviceSerial) + controlService.HandleScrollEvent(msg, deviceSerial) case "clipboard_set": - h.handleClipboardMessage(conn, msg, deviceSerial) + controlService.HandleClipboardEvent(msg, deviceSerial) case "reset_video": - h.handleResetVideoMessage(conn, msg, deviceSerial) + controlService.HandleVideoResetEvent(msg, deviceSerial) default: log.Printf("Unknown control message type: %s", msgType) @@ -329,1219 +323,26 @@ func (h *StreamingHandlers) HandleControlWebSocket(w http.ResponseWriter, r *htt // delegateToWebRTCHandler forwards WebRTC signaling messages to the specialized handler func (h *StreamingHandlers) delegateToWebRTCHandler(conn *websocket.Conn, msg map[string]interface{}, msgType, deviceSerial string) { + if h.webrtcHandlers == nil { + log.Printf("WebRTC handlers not initialized") + return + } + switch msgType { case "ping": - h.webrtcHandlers.handlePing(conn, msg) + h.webrtcHandlers.HandlePing(conn, msg) case "offer": - h.webrtcHandlers.handleOffer(conn, msg, deviceSerial) + h.webrtcHandlers.HandleOffer(conn, msg, deviceSerial) case "answer": - h.webrtcHandlers.handleAnswer(conn, msg, deviceSerial) + h.webrtcHandlers.HandleAnswer(conn, msg, deviceSerial) case "ice-candidate": - h.webrtcHandlers.handleIceCandidate(conn, msg, deviceSerial) - } -} - -// Helper function to parse device serial from various URL formats -func extractDeviceSerial(path string) string { - // Remove common prefixes - path = strings.TrimPrefix(path, "/stream/video/") - path = strings.TrimPrefix(path, "/stream/ws/") - path = strings.TrimPrefix(path, "/api/v1/devices/") - - // Split by / and ? to get just the device serial - parts := strings.FieldsFunc(path, func(c rune) bool { - return c == '/' || c == '?' - }) - - if len(parts) > 0 { - return parts[0] - } - - return "" -} - -// Helper function to validate device serial format -func isValidDeviceSerial(serial string) bool { - if serial == "" { - return false - } - - // Basic validation - should be alphanumeric with possible special chars - if len(serial) < 1 || len(serial) > 64 { - return false - } - - // Allow alphanumeric, dots, dashes, underscores - for _, c := range serial { - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || - (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_') { - return false - } - } - - return true -} - -// Helper function to parse quality parameter -func parseQuality(qualityStr string) int { - if qualityStr == "" { - return 80 // Default quality - } - - quality, err := strconv.Atoi(qualityStr) - if err != nil || quality < 1 || quality > 100 { - return 80 // Default on invalid input - } - - return quality -} - -// Control message handlers - -func (h *StreamingHandlers) handleTouchMessage(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { - action, _ := msg["action"].(string) - x, _ := msg["x"].(float64) - y, _ := msg["y"].(float64) - pressure, _ := msg["pressure"].(float64) - pointerId, _ := msg["pointerId"].(float64) - - log.Printf("Touch event: device=%s, action=%s, x=%.3f, y=%.3f, pressure=%.2f, pointerId=%.0f", - deviceSerial, action, x, y, pressure, pointerId) - - // Forward touch event to bridge manager - if h.serverService != nil { - bridge, exists := h.serverService.GetBridge(deviceSerial) - if exists && bridge != nil { - bridge.HandleTouchEvent(msg) - } else { - log.Printf("Bridge not found for device %s", deviceSerial) - } - } -} - -func (h *StreamingHandlers) handleKeyMessage(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { - action, _ := msg["action"].(string) - keycode, _ := msg["keycode"].(float64) - metaState, _ := msg["metaState"].(float64) - - log.Printf("Key event: device=%s, action=%s, keycode=%.0f, metaState=%.0f", - deviceSerial, action, keycode, metaState) - - // Forward key event to bridge manager - if h.serverService != nil { - bridge, exists := h.serverService.GetBridge(deviceSerial) - if exists && bridge != nil { - bridge.HandleKeyEvent(msg) - } else { - log.Printf("Bridge not found for device %s", deviceSerial) - } - } -} - -func (h *StreamingHandlers) handleScrollMessage(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { - x, _ := msg["x"].(float64) - y, _ := msg["y"].(float64) - hScroll, _ := msg["hScroll"].(float64) - vScroll, _ := msg["vScroll"].(float64) - - log.Printf("Scroll event: device=%s, x=%.3f, y=%.3f, hScroll=%.2f, vScroll=%.2f", - deviceSerial, x, y, hScroll, vScroll) - - // Forward scroll event to bridge manager - if h.serverService != nil { - bridge, exists := h.serverService.GetBridge(deviceSerial) - if exists && bridge != nil { - bridge.HandleScrollEvent(msg) - } else { - log.Printf("Bridge not found for device %s", deviceSerial) - } - } -} - -func (h *StreamingHandlers) handleClipboardMessage(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { - text, _ := msg["text"].(string) - paste, _ := msg["paste"].(bool) - - log.Printf("Clipboard event: device=%s, text_length=%d, paste=%t", - deviceSerial, len(text), paste) - - // Clipboard handling - currently not implemented in bridge, so just log - // TODO: Implement clipboard handling when bridge supports it - log.Printf("Clipboard message received but bridge clipboard handling not yet implemented") -} - -func (h *StreamingHandlers) handleResetVideoMessage(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { - log.Printf("Reset video event: device=%s", deviceSerial) - - // Video reset handling - currently not implemented in bridge, so just log - // TODO: Implement video reset handling when bridge supports it - log.Printf("Reset video message received but bridge video reset handling not yet implemented") -} - -// handleOpusAudioStream handles Opus audio WebSocket connections -func (h *StreamingHandlers) handleOpusAudioStream(w http.ResponseWriter, r *http.Request, deviceSerial string) { - logger := slog.With("device", deviceSerial) - logger.Info("🎵 Starting Opus audio WebSocket stream", "url", r.URL.String()) - - // Upgrade to WebSocket - upgrader := websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true // Allow all origins for now - }, - } - - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - logger.Error("❌ Failed to upgrade to WebSocket", "error", err) - return - } - defer conn.Close() - - logger.Info("✅ Opus audio WebSocket connection established") - - // Get audio stream from device source - source := scrcpy.GetSource(deviceSerial) - if source == nil { - logger.Error("❌ Device source not found - is scrcpy running for this device?") - conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "Device not connected")) - return - } - - logger.Info("✅ Found scrcpy source for audio streaming") - - // Subscribe to audio stream - subscriberID := fmt.Sprintf("audio_ws_%p", conn) - audioCh := source.SubscribeAudio(subscriberID, 100) - defer source.UnsubscribeAudio(subscriberID) - - logger.Info("Subscribed to audio stream", "subscriberID", subscriberID) - - // Handle client messages in separate goroutine - go func() { - for { - _, _, err := conn.ReadMessage() - if err != nil { - logger.Info("Audio WebSocket client disconnected", "error", err) - return - } - } - }() - - // Stream audio data to client - audioFrameCount := 0 - logger.Info("🎵 Starting to stream audio data to WebSocket client") - - for audioSample := range audioCh { - audioFrameCount++ - - // Log first few frames to verify we're receiving audio data - if audioFrameCount <= 5 || audioFrameCount%50 == 0 { - logger.Info("🎵 Received audio sample", "frame", audioFrameCount, "size", len(audioSample.Data)) - } - - // Send Opus data wrapped in minimal OGG container for browser compatibility - if len(audioSample.Data) > 0 { - // Check if client requested OGG format - format := r.URL.Query().Get("format") - var dataToSend []byte - - if format == "ogg" { - // Wrap Opus data in minimal OGG page - dataToSend = h.wrapOpusInOgg(audioSample.Data, audioFrameCount) - } else { - // Send raw Opus data - dataToSend = audioSample.Data - } - - err := conn.WriteMessage(websocket.BinaryMessage, dataToSend) - if err != nil { - logger.Error("❌ Failed to send audio data to WebSocket", "error", err, "frame", audioFrameCount) - break - } - - // Log successful transmission for first few frames - if audioFrameCount <= 5 { - logger.Info("✅ Successfully sent audio data to WebSocket", "frame", audioFrameCount, "size", len(dataToSend), "format", format) - } - } else { - logger.Warn("⚠️ Received empty audio sample", "frame", audioFrameCount) - } - } - - logger.Info("🎵 Audio stream ended", "totalFrames", audioFrameCount) -} - -// wrapOpusInOgg wraps Opus audio data in a minimal OGG container for browser playback -func (h *StreamingHandlers) wrapOpusInOgg(opusData []byte, frameNumber int) []byte { - // Send only OpusHead for first frame - if frameNumber == 1 { - return h.createOpusHead() - } - - // Send only OpusTags for second frame - if frameNumber == 2 { - return h.createOpusTags() - } - - // For frame 3 onwards, send audio data - if frameNumber >= 3 { - return h.createOpusAudioPage(opusData, frameNumber) // Use frame number directly for proper sequencing - } - - // Should never reach here - return []byte{} -} - -// createOpusAudioPage creates an OGG page containing Opus audio data -func (h *StreamingHandlers) createOpusAudioPage(opusData []byte, pageSeq int) []byte { - segmentLength := len(opusData) - if segmentLength > 255 { - segmentLength = 255 // OGG segment max length - opusData = opusData[:255] - } - - // Calculate granule position (cumulative sample position) - // For 20ms frames at 48kHz: each frame = 960 samples - // Page sequence starts at 3 for audio data (after OpusHead=0, OpusTags=1) - granulePos := uint64(pageSeq-2) * 960 // pageSeq 3 -> granule 960, pageSeq 4 -> granule 1920, etc. - - oggHeader := []byte{ - 0x4F, 0x67, 0x67, 0x53, // "OggS" magic signature - 0x00, // Version - 0x00, // Header type (0x00 = continuation) - byte(granulePos), byte(granulePos >> 8), byte(granulePos >> 16), byte(granulePos >> 24), // Granule position (lower 4 bytes) - byte(granulePos >> 32), byte(granulePos >> 40), byte(granulePos >> 48), byte(granulePos >> 56), // Granule position (upper 4 bytes) - 0x01, 0x00, 0x00, 0x00, // Serial number (4 bytes) - stream 1 - byte(pageSeq & 0xFF), byte((pageSeq >> 8) & 0xFF), byte((pageSeq >> 16) & 0xFF), byte((pageSeq >> 24) & 0xFF), // Page sequence number - 0x00, 0x00, 0x00, 0x00, // CRC checksum (4 bytes) - calculated below - 0x01, // Number of page segments - byte(segmentLength), // Segment table - } - - // Combine header with data - result := make([]byte, len(oggHeader)+len(opusData)) - copy(result, oggHeader) - copy(result[len(oggHeader):], opusData) - - // Calculate and set CRC checksum - crc := h.calculateOggCRC(result) - result[22] = byte(crc & 0xFF) - result[23] = byte((crc >> 8) & 0xFF) - result[24] = byte((crc >> 16) & 0xFF) - result[25] = byte((crc >> 24) & 0xFF) - - return result -} - -// createOpusHead creates the OpusHead identification header -func (h *StreamingHandlers) createOpusHead() []byte { - opusHead := []byte{ - 0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead" - 0x01, // Version - 0x02, // Channel count (stereo) - 0x00, 0x00, // Pre-skip (0 samples) - little endian, let decoder handle - 0x80, 0xBB, 0x00, 0x00, // Sample rate (48000 Hz) - little endian - 0x00, 0x00, // Output gain (0 dB) - little endian - 0x00, // Channel mapping family (0 = RTP mapping) - } - - // Wrap in OGG page - oggHeader := []byte{ - 0x4F, 0x67, 0x67, 0x53, // "OggS" - 0x00, // Version - 0x02, // Header type (beginning of stream) - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Granule position - 0x01, 0x00, 0x00, 0x00, // Serial number - 0x00, 0x00, 0x00, 0x00, // Page sequence (0) - 0x00, 0x00, 0x00, 0x00, // CRC - 0x01, // Segments - byte(len(opusHead)), // Segment length - } - - result := make([]byte, len(oggHeader)+len(opusHead)) - copy(result, oggHeader) - copy(result[len(oggHeader):], opusHead) - - // Calculate and set CRC - crc := h.calculateOggCRC(result) - result[22] = byte(crc & 0xFF) - result[23] = byte((crc >> 8) & 0xFF) - result[24] = byte((crc >> 16) & 0xFF) - result[25] = byte((crc >> 24) & 0xFF) - - return result -} - -// createOpusTags creates the OpusTags comment header -func (h *StreamingHandlers) createOpusTags() []byte { - vendor := "libopus" - opusTags := make([]byte, 0) - - // OpusTags header - opusTags = append(opusTags, []byte("OpusTags")...) - - // Vendor string length (little endian) - vendorLen := len(vendor) - opusTags = append(opusTags, byte(vendorLen), byte(vendorLen>>8), byte(vendorLen>>16), byte(vendorLen>>24)) - - // Vendor string - opusTags = append(opusTags, vendor...) - - // User comment list length (0 comments) - opusTags = append(opusTags, 0x00, 0x00, 0x00, 0x00) - - // Wrap in OGG page - oggHeader := []byte{ - 0x4F, 0x67, 0x67, 0x53, // "OggS" - 0x00, // Version - 0x00, // Header type (continuation) - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Granule position - 0x01, 0x00, 0x00, 0x00, // Serial number - 0x01, 0x00, 0x00, 0x00, // Page sequence (1) - 0x00, 0x00, 0x00, 0x00, // CRC - 0x01, // Segments - byte(len(opusTags)), // Segment length + h.webrtcHandlers.HandleIceCandidate(conn, msg, deviceSerial) } - - result := make([]byte, len(oggHeader)+len(opusTags)) - copy(result, oggHeader) - copy(result[len(oggHeader):], opusTags) - - // Calculate and set CRC - crc := h.calculateOggCRC(result) - result[22] = byte(crc & 0xFF) - result[23] = byte((crc >> 8) & 0xFF) - result[24] = byte((crc >> 16) & 0xFF) - result[25] = byte((crc >> 24) & 0xFF) - - return result -} - -// OGG CRC lookup table -var oggCRCTable = [256]uint32{ - 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, 0x130476dc, 0x17c56b6b, - 0x1a864db2, 0x1e475005, 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, - 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd, 0x4c11db70, 0x48d0c6c7, - 0x4593e01e, 0x4152fda9, 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75, - 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011, 0x791d4014, 0x7ddc5da3, - 0x709f7b7a, 0x745e66cd, 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039, - 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5, 0xbe2b5b58, 0xbaea46ef, - 0xb7a96036, 0xb3687d81, 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d, - 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49, 0xc7361b4c, 0xc3f706fb, - 0xceb42022, 0xca753d95, 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1, - 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d, 0x34867077, 0x30476dc0, - 0x3d044b19, 0x39c556ae, 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072, - 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16, 0x018aeb13, 0x054bf6a4, - 0x0808d07d, 0x0cc9cdca, 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde, - 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02, 0x5e9f46bf, 0x5a5e5b08, - 0x571d7dd1, 0x53dc6066, 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba, - 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e, 0xbfa1b04b, 0xbb60adfc, - 0xb6238b25, 0xb2e29692, 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6, - 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a, 0xe0b41de7, 0xe4750050, - 0xe9362689, 0xedf73b3e, 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2, - 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686, 0xd5b88683, 0xd1799b34, - 0xdc3abded, 0xd8fba05a, 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637, - 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb, 0x4f040d56, 0x4bc510e1, - 0x46863638, 0x42472b8f, 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53, - 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47, 0x36194d42, 0x32d850f5, - 0x3f9b762c, 0x3b5a6b9b, 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff, - 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623, 0xf12f560e, 0xf5ee4bb9, - 0xf8ad6d60, 0xfc6c70d7, 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b, - 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f, 0xc423cd6a, 0xc0e2d0dd, - 0xcda1f604, 0xc960ebb3, 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7, - 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b, 0x9b3660c6, 0x9ff77d71, - 0x92b45ba8, 0x9675461f, 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3, - 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640, 0x4e8ee645, 0x4a4ffbf2, - 0x470cdd2b, 0x43cdc09c, 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8, - 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24, 0x119b4be9, 0x155a565e, - 0x18197087, 0x1cd86d30, 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec, - 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088, 0x2497d08d, 0x2056cd3a, - 0x2d15ebe3, 0x29d4f654, 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0, - 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c, 0xe3a1cbc1, 0xe760d676, - 0xea23f0af, 0xeee2ed18, 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4, - 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0, 0x9abc8bd5, 0x9e7d9662, - 0x933eb0bb, 0x97ffad0c, 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668, - 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4, -} - -// calculateOggCRC calculates proper OGG CRC-32 checksum -func (h *StreamingHandlers) calculateOggCRC(data []byte) uint32 { - crc := uint32(0) - - // Set CRC field to 0 before calculation - if len(data) >= 26 { - data[22] = 0 - data[23] = 0 - data[24] = 0 - data[25] = 0 - } - - for _, b := range data { - crc = (crc << 8) ^ oggCRCTable[((crc>>24)^uint32(b))&0xFF] - } - - return crc -} - -// createTestOGG creates a minimal test OGG file with silence to validate format -func (h *StreamingHandlers) createTestOGG() []byte { - // Create OpusHead page - opusHead := h.createOpusHead() - - // Create OpusTags page - opusTags := h.createOpusTags() - - // Create a few silence frames (Opus silence frame is just TOC byte + minimal data) - silenceFrame := []byte{0xFC, 0x00} // TOC byte + minimal silence data - - // Create audio pages with silence - audioPage1 := h.createOpusAudioPage(silenceFrame, 2) - audioPage2 := h.createOpusAudioPage(silenceFrame, 3) - audioPage3 := h.createOpusAudioPage(silenceFrame, 4) - - // Combine all pages - totalLen := len(opusHead) + len(opusTags) + len(audioPage1) + len(audioPage2) + len(audioPage3) - result := make([]byte, totalLen) - - offset := 0 - copy(result[offset:], opusHead) - offset += len(opusHead) - - copy(result[offset:], opusTags) - offset += len(opusTags) - - copy(result[offset:], audioPage1) - offset += len(audioPage1) - - copy(result[offset:], audioPage2) - offset += len(audioPage2) - - copy(result[offset:], audioPage3) - - return result } -// handleOpusAudioHTTP handles HTTP-based Opus audio streaming for direct browser testing -func (h *StreamingHandlers) handleOpusAudioHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { - logger := slog.With("device", deviceSerial) - logger.Info("🎵 Starting Opus audio HTTP stream", "url", r.URL.String()) - - // Set headers for audio streaming - format := r.URL.Query().Get("format") - saveFile := r.URL.Query().Get("save") == "true" - debug := r.URL.Query().Get("debug") == "true" - test := r.URL.Query().Get("test") == "true" - - if test { - // Send a minimal test OGG file to validate our format - w.Header().Set("Content-Type", "audio/ogg; codecs=opus") - logger.Info("🧪 Serving test OGG file") - testData := h.createTestOGG() - w.Write(testData) - return - } else if debug { - w.Header().Set("Content-Type", "text/plain") - logger.Info("🔍 Serving debug mode - will show hex dump of first 10 frames") - } else if format == "ogg" { - if saveFile { - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Disposition", "attachment; filename=\"audio.ogg\"") - logger.Info("🎵 Serving OGG/Opus as download") - } else { - w.Header().Set("Content-Type", "audio/ogg; codecs=opus") - logger.Info("🎵 Serving OGG/Opus format") - } - } else { - if saveFile { - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Disposition", "attachment; filename=\"audio.opus\"") - logger.Info("🎵 Serving raw Opus as download") - } else { - w.Header().Set("Content-Type", "audio/opus") - logger.Info("🎵 Serving raw Opus format") - } - } - - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - // Get audio stream from device source - source := scrcpy.GetSource(deviceSerial) - if source == nil { - logger.Error("❌ Device source not found - is scrcpy running for this device?") - http.Error(w, "Device not connected", http.StatusServiceUnavailable) - return - } - logger.Info("✅ Found scrcpy source for HTTP audio streaming") - - // Subscribe to audio stream - subscriberID := fmt.Sprintf("audio_http_%p", w) - audioCh := source.SubscribeAudio(subscriberID, 100) - defer source.UnsubscribeAudio(subscriberID) - - logger.Info("🎵 Subscribed to audio stream", "subscriberID", subscriberID) - - // Stream audio data to client - audioFrameCount := 0 - logger.Info("🎵 Starting to stream audio data to HTTP client") - - for { - select { - case <-r.Context().Done(): - logger.Info("🎵 HTTP audio stream context cancelled") - return - - case audioSample, ok := <-audioCh: - if !ok { - logger.Info("🎵 HTTP audio channel closed") - return - } - - audioFrameCount++ - - // Log first few frames to verify we're receiving audio data - if audioFrameCount <= 5 || audioFrameCount%50 == 0 { - logger.Info("🎵 Received audio sample for HTTP", "frame", audioFrameCount, "size", len(audioSample.Data)) - } - - // Debug: Log raw data for first few frames to understand format - if audioFrameCount <= 3 && len(audioSample.Data) > 0 { - hexData := "" - dataLen := len(audioSample.Data) - if dataLen > 16 { - dataLen = 16 - } - for i, b := range audioSample.Data[:dataLen] { - if i > 0 { - hexData += " " - } - hexData += fmt.Sprintf("%02x", b) - } - logger.Info("🔍 Raw audio data", "frame", audioFrameCount, "hex", hexData) - } - - // Send audio data - if len(audioSample.Data) > 0 { - var dataToSend []byte - - if debug { - // Debug mode: output hex dump of first 10 frames - if audioFrameCount <= 10 { - debugText := fmt.Sprintf("Frame %d (size %d bytes):\n", audioFrameCount, len(audioSample.Data)) - - // Add hex dump - dataLen := len(audioSample.Data) - if dataLen > 64 { - dataLen = 64 // Limit to first 64 bytes - } - - for i := 0; i < dataLen; i += 16 { - end := i + 16 - if end > dataLen { - end = dataLen - } - - // Hex part - hexPart := "" - for j := i; j < end; j++ { - hexPart += fmt.Sprintf("%02x ", audioSample.Data[j]) - } - // Pad hex part to 48 characters (16 * 3) - for len(hexPart) < 48 { - hexPart += " " - } - - // ASCII part - asciiPart := "" - for j := i; j < end; j++ { - if audioSample.Data[j] >= 32 && audioSample.Data[j] <= 126 { - asciiPart += string(audioSample.Data[j]) - } else { - asciiPart += "." - } - } - - debugText += fmt.Sprintf("%04x: %s |%s|\n", i, hexPart, asciiPart) - } - debugText += "\n" - - if _, err := w.Write([]byte(debugText)); err != nil { - logger.Error("❌ Failed to write debug data", "error", err) - return - } - } - - // Stop after 10 frames in debug mode - if audioFrameCount >= 10 { - return - } - } else if format == "ogg" { - // Wrap Opus data in minimal OGG page - dataToSend = h.wrapOpusInOgg(audioSample.Data, audioFrameCount) - - // Debug: Log first few OGG pages - if audioFrameCount <= 3 { - logger.Info("🔧 OGG page created", "frame", audioFrameCount, "size", len(dataToSend)) - if len(dataToSend) >= 4 { - header := string(dataToSend[0:4]) - logger.Info("🔧 OGG page header", "frame", audioFrameCount, "header", header) - } - } - } else { - // Send raw Opus data - dataToSend = audioSample.Data - } - - if !debug { - if _, err := w.Write(dataToSend); err != nil { - logger.Error("❌ Failed to write audio data to HTTP response", "error", err, "frame", audioFrameCount) - return - } - - // Flush data immediately for low latency - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - - // Log successful transmission for first few frames - if audioFrameCount <= 5 { - logger.Info("✅ Successfully sent audio data to HTTP client", "frame", audioFrameCount, "size", len(dataToSend), "format", format) - } - } - } else { - logger.Warn("⚠️ Received empty audio sample for HTTP", "frame", audioFrameCount) - } - } - } -} - -// handleRTPOpusHTTP handles RTP-wrapped Opus audio streaming for FFmpeg compatibility -func (h *StreamingHandlers) handleRTPOpusHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { - logger := slog.With("device", deviceSerial) - logger.Info("🎵 Starting RTP/Opus audio HTTP stream", "url", r.URL.String()) - - // Set headers for RTP streaming - w.Header().Set("Content-Type", "application/rtp") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - // Get audio stream from device source - source := scrcpy.GetSource(deviceSerial) - if source == nil { - logger.Error("❌ Device source not found - is scrcpy running for this device?") - http.Error(w, "Device not connected", http.StatusServiceUnavailable) - return - } - - logger.Info("✅ Found scrcpy source for RTP/Opus streaming") - - // Subscribe to audio stream - subscriberID := fmt.Sprintf("audio_rtp_%p", w) - audioCh := source.SubscribeAudio(subscriberID, 100) - defer source.UnsubscribeAudio(subscriberID) - - logger.Info("🎵 Subscribed to RTP/Opus stream", "subscriberID", subscriberID) - - // Stream RTP-wrapped audio data to client - audioFrameCount := 0 - sequenceNumber := uint16(1) - timestamp := uint32(0) - ssrc := uint32(0x12345678) // Random SSRC - - logger.Info("🎵 Starting to stream RTP/Opus data to HTTP client") - - for { - select { - case <-r.Context().Done(): - logger.Info("🎵 RTP/Opus stream context cancelled") - return - - case audioSample, ok := <-audioCh: - if !ok { - logger.Info("🎵 RTP/Opus channel closed") - return - } - - audioFrameCount++ - - // Log first few frames to verify we're receiving audio data - if audioFrameCount <= 5 || audioFrameCount%50 == 0 { - logger.Info("🎵 Received audio sample for RTP", "frame", audioFrameCount, "size", len(audioSample.Data)) - } - - // Wrap Opus data in RTP packet - if len(audioSample.Data) > 0 { - rtpPacket := h.createRTPPacket(audioSample.Data, sequenceNumber, timestamp, ssrc) - - if _, err := w.Write(rtpPacket); err != nil { - logger.Error("❌ Failed to write RTP data to HTTP response", "error", err, "frame", audioFrameCount) - return - } - - // Flush data immediately for low latency - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - - // Update RTP sequence and timestamp - sequenceNumber++ - timestamp += 960 // 20ms at 48kHz = 960 samples - - // Log successful transmission for first few frames - if audioFrameCount <= 5 { - logger.Info("✅ Successfully sent RTP/Opus data to HTTP client", "frame", audioFrameCount, "size", len(rtpPacket)) - } - } else { - logger.Warn("⚠️ Received empty audio sample for RTP", "frame", audioFrameCount) - } - } - } -} - -// createRTPPacket creates an RTP packet for Opus audio data -func (h *StreamingHandlers) createRTPPacket(opusData []byte, seqNum uint16, timestamp uint32, ssrc uint32) []byte { - // RTP header: 12 bytes - // V(2) P(1) X(1) CC(4) = 0x80 (version 2, no padding, no extension, no CSRC) - // M(1) PT(7) = 0x60 (marker=0, payload type 96 for dynamic Opus) - // Sequence number (2 bytes) - // Timestamp (4 bytes) - // SSRC (4 bytes) - - rtpHeader := make([]byte, 12) - rtpHeader[0] = 0x80 // V=2, P=0, X=0, CC=0 - rtpHeader[1] = 96 // M=0, PT=96 (dynamic payload type for Opus) - - // Sequence number (big endian) - rtpHeader[2] = byte(seqNum >> 8) - rtpHeader[3] = byte(seqNum & 0xFF) - - // Timestamp (big endian) - rtpHeader[4] = byte(timestamp >> 24) - rtpHeader[5] = byte(timestamp >> 16) - rtpHeader[6] = byte(timestamp >> 8) - rtpHeader[7] = byte(timestamp & 0xFF) - - // SSRC (big endian) - rtpHeader[8] = byte(ssrc >> 24) - rtpHeader[9] = byte(ssrc >> 16) - rtpHeader[10] = byte(ssrc >> 8) - rtpHeader[11] = byte(ssrc & 0xFF) - - // Combine header with Opus payload - rtpPacket := make([]byte, len(rtpHeader)+len(opusData)) - copy(rtpPacket, rtpHeader) - copy(rtpPacket[len(rtpHeader):], opusData) - - return rtpPacket -} - -// handleSDPFile generates an SDP file for RTP/Opus streaming -func (h *StreamingHandlers) handleSDPFile(w http.ResponseWriter, r *http.Request, deviceSerial string) { - logger := slog.With("device", deviceSerial) - logger.Info("📄 Serving SDP file for RTP/Opus stream", "device", deviceSerial) - - // Build the RTP stream URL - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - - host := r.Host - if host == "" { - host = "localhost:29888" // fallback - } - - rtpURL := fmt.Sprintf("%s://%s/api/stream/audio/%s?codec=rtp", scheme, host, deviceSerial) - - // Generate SDP content - sdpContent := fmt.Sprintf(`v=0 -o=- 0 0 IN IP4 127.0.0.1 -s=Scrcpy Audio Stream -c=IN IP4 127.0.0.1 -t=0 0 -m=audio 0 RTP/AVP 96 -a=rtpmap:96 opus/48000/2 -a=fmtp:96 sprop-stereo=1 -a=tool:scrcpy-gbox -a=source-filter: incl IN IP4 127.0.0.1 %s -`, rtpURL) - - // Set content type and headers - w.Header().Set("Content-Type", "application/sdp") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s_audio.sdp\"", deviceSerial)) - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Access-Control-Allow-Origin", "*") - - // Write SDP content - if _, err := w.Write([]byte(sdpContent)); err != nil { - logger.Error("Failed to write SDP content", "error", err) - return - } - - logger.Info("✅ SDP file served successfully", "device", deviceSerial) -} - -// handleWebMOpusHTTP handles WebM-wrapped Opus audio streaming for FFmpeg compatibility -func (h *StreamingHandlers) handleWebMOpusHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { - logger := slog.With("device", deviceSerial) - logger.Info("🎵 Starting WebM/Opus audio HTTP stream", "url", r.URL.String()) - - // Set headers for WebM streaming - w.Header().Set("Content-Type", "audio/webm; codecs=opus") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - // Get audio stream from device source - source := scrcpy.GetSource(deviceSerial) - if source == nil { - logger.Error("❌ Device source not found - is scrcpy running for this device?") - http.Error(w, "Device not connected", http.StatusServiceUnavailable) - return - } - - logger.Info("✅ Found scrcpy source for WebM/Opus streaming") - - // Subscribe to audio stream - subscriberID := fmt.Sprintf("audio_webm_%p", w) - audioCh := source.SubscribeAudio(subscriberID, 100) - defer source.UnsubscribeAudio(subscriberID) - - logger.Info("🎵 Subscribed to WebM/Opus stream", "subscriberID", subscriberID) - - // Write WebM header - webmHeader := h.createWebMHeader() - if _, err := w.Write(webmHeader); err != nil { - logger.Error("❌ Failed to write WebM header", "error", err) - return - } - - logger.Info("✅ WebM header sent", "size", len(webmHeader)) - - // Stream WebM-wrapped audio data to client - audioFrameCount := 0 - timestamp := uint64(0) - - logger.Info("🎵 Starting to stream WebM/Opus data to HTTP client") - - for { - select { - case <-r.Context().Done(): - logger.Info("🎵 WebM/Opus stream context cancelled") - return - - case audioSample, ok := <-audioCh: - if !ok { - logger.Info("🎵 WebM/Opus channel closed") - return - } - - audioFrameCount++ - - // Log first few frames to verify we're receiving audio data - if audioFrameCount <= 5 || audioFrameCount%50 == 0 { - logger.Info("🎵 Received audio sample for WebM", "frame", audioFrameCount, "size", len(audioSample.Data)) - } - - // Wrap Opus data in WebM SimpleBlock - if len(audioSample.Data) > 0 { - webmBlock := h.createWebMSimpleBlock(audioSample.Data, timestamp, audioFrameCount == 1) - - if _, err := w.Write(webmBlock); err != nil { - logger.Error("❌ Failed to write WebM data to HTTP response", "error", err, "frame", audioFrameCount) - return - } - - // Flush data immediately for low latency - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - - // Update timestamp (20ms per frame = 960 samples at 48kHz) - timestamp += 20 // milliseconds - - // Log successful transmission for first few frames - if audioFrameCount <= 5 { - logger.Info("✅ Successfully sent WebM/Opus data to HTTP client", "frame", audioFrameCount, "size", len(webmBlock)) - } - } else { - logger.Warn("⚠️ Received empty audio sample for WebM", "frame", audioFrameCount) - } - } - } -} - -// createWebMHeader creates a minimal WebM header for Opus audio -func (h *StreamingHandlers) createWebMHeader() []byte { - // This is a simplified WebM header for audio-only Opus stream - // EBML Header + Segment + Info + Tracks - header := []byte{ - // EBML Header - 0x1A, 0x45, 0xDF, 0xA3, // EBML ID - 0x9F, // Size (unknown/live stream) - 0x42, 0x86, 0x81, 0x01, // EBMLVersion = 1 - 0x42, 0xF7, 0x81, 0x01, // EBMLReadVersion = 1 - 0x42, 0xF2, 0x81, 0x04, // EBMLMaxIDLength = 4 - 0x42, 0xF3, 0x81, 0x08, // EBMLMaxSizeLength = 8 - 0x42, 0x82, 0x88, 0x77, 0x65, 0x62, 0x6D, 0x00, 0x00, 0x00, 0x00, // DocType = "webm" - 0x42, 0x87, 0x81, 0x04, // DocTypeVersion = 4 - 0x42, 0x85, 0x81, 0x02, // DocTypeReadVersion = 2 - - // Segment - 0x18, 0x53, 0x80, 0x67, // Segment ID - 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // Size (unknown/live stream) - - // Info - 0x15, 0x49, 0xA9, 0x66, // Info ID - 0x8A, // Size = 10 - 0x2A, 0xD7, 0xB1, 0x83, 0x0F, 0x42, 0x40, // TimestampScale = 1000000 (1ms) - - // Tracks - 0x16, 0x54, 0xAE, 0x6B, // Tracks ID - 0x90, // Size = 16 - // TrackEntry - 0xAE, // TrackEntry ID - 0x8D, // Size = 13 - 0xD7, 0x81, 0x01, // TrackNumber = 1 - 0x73, 0xC5, 0x81, 0x01, // TrackUID = 1 - 0x83, 0x81, 0x02, // TrackType = 2 (audio) - 0x86, 0x85, 0x6F, 0x70, 0x75, 0x73, 0x00, // CodecID = "A_OPUS" - } - - return header -} - -// createWebMSimpleBlock creates a WebM SimpleBlock for Opus audio data -func (h *StreamingHandlers) createWebMSimpleBlock(opusData []byte, timestamp uint64, keyframe bool) []byte { - // SimpleBlock: Element ID + Size + Track Number + Timestamp + Flags + Data - - // Calculate size (track number + timestamp + flags + data) - dataSize := 1 + 2 + 1 + len(opusData) // track(1) + timestamp(2) + flags(1) + data - - block := make([]byte, 0, 4+8+dataSize) // ID(4) + size(up to 8) + data - - // SimpleBlock Element ID - block = append(block, 0xA3) - - // Size (variable length encoding) - if dataSize < 127 { - block = append(block, 0x80|byte(dataSize)) - } else { - block = append(block, 0x40, byte(dataSize>>8), byte(dataSize&0xFF)) - } - - // Track number (1 = audio track) - block = append(block, 0x81) // Track 1 - - // Timestamp (relative to cluster, 16-bit signed) - block = append(block, byte(timestamp>>8), byte(timestamp&0xFF)) - - // Flags (keyframe flag) - flags := byte(0x00) - if keyframe { - flags |= 0x80 // Keyframe flag - } - block = append(block, flags) - - // Opus data - block = append(block, opusData...) - - return block -} - -// handleOpusStreamHTTP handles properly formatted Opus audio streaming -func (h *StreamingHandlers) handleOpusStreamHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { - logger := slog.With("device", deviceSerial) - logger.Info("🎵 Starting Opus stream HTTP", "url", r.URL.String()) - - // Set headers for Opus streaming - w.Header().Set("Content-Type", "audio/opus") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - // Get audio stream from device source - source := scrcpy.GetSource(deviceSerial) - if source == nil { - logger.Error("❌ Device source not found - is scrcpy running for this device?") - http.Error(w, "Device not connected", http.StatusServiceUnavailable) - return - } - - logger.Info("✅ Found scrcpy source for Opus streaming") - - // Subscribe to audio stream - subscriberID := fmt.Sprintf("audio_opus_stream_%p", w) - audioCh := source.SubscribeAudio(subscriberID, 100) - defer source.UnsubscribeAudio(subscriberID) - - logger.Info("🎵 Subscribed to Opus stream", "subscriberID", subscriberID) - - // Process audio stream - audioFrameCount := 0 - - logger.Info("🎵 Starting to stream Opus data to HTTP client") - - for { - select { - case <-r.Context().Done(): - logger.Info("🎵 Opus stream context cancelled") - return - - case audioSample, ok := <-audioCh: - if !ok { - logger.Info("🎵 Opus channel closed") - return - } - - audioFrameCount++ - - // Log first few frames - if audioFrameCount <= 5 || audioFrameCount%50 == 0 { - logger.Info("🎵 Received audio sample for Opus stream", "frame", audioFrameCount, "size", len(audioSample.Data)) - } - - if len(audioSample.Data) > 0 { - // Handle all packets like WebRTC does - no filtering, just send them - // Log detailed info for first few packets to understand the stream - if audioFrameCount <= 10 { - isOpusHead := h.isOpusConfigPacket(audioSample.Data) - logger.Info("🎵 Audio packet details", "frame", audioFrameCount, "size", len(audioSample.Data), "isOpusHead", isOpusHead) - - // Show hex dump for very first packet - if audioFrameCount == 1 { - hexData := "" - dataLen := min(len(audioSample.Data), 32) - for i, b := range audioSample.Data[:dataLen] { - if i > 0 { - hexData += " " - } - hexData += fmt.Sprintf("%02x", b) - } - logger.Info("🔍 First packet hex", "hex", hexData) - } - } - - // Send all audio data just like WebRTC does - if _, err := w.Write(audioSample.Data); err != nil { - logger.Error("❌ Failed to write Opus data to HTTP response", "error", err, "frame", audioFrameCount) - return - } - - // Flush data immediately for low latency - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - - // Log successful transmission for first few frames - if audioFrameCount <= 5 { - logger.Info("✅ Successfully sent Opus data to HTTP client", "frame", audioFrameCount, "size", len(audioSample.Data)) - } - } else { - logger.Warn("⚠️ Received empty audio sample for Opus stream", "frame", audioFrameCount) - } - } - } -} - -// isOpusConfigPacket checks if the data contains an Opus configuration packet -func (h *StreamingHandlers) isOpusConfigPacket(data []byte) bool { - // Check for OpusHead signature - opusHead := []byte("OpusHead") - if len(data) >= len(opusHead) { - for i, b := range opusHead { - if data[i] != b { - return false - } - } - return true - } - return false -} - -// handleTestAudioHTTP handles test audio streaming that mimics WebRTC exactly -func (h *StreamingHandlers) handleTestAudioHTTP(w http.ResponseWriter, r *http.Request, deviceSerial string) { - logger := slog.With("device", deviceSerial) - logger.Info("🧪 Starting test audio HTTP stream", "url", r.URL.String()) - - // Set headers - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") - - // Get audio stream from device source - source := scrcpy.GetSource(deviceSerial) - if source == nil { - logger.Error("❌ Device source not found") - http.Error(w, "Device not connected", http.StatusServiceUnavailable) - return - } - - // Subscribe to audio stream - exactly like WebRTC does - audioCh := source.SubscribeAudio("test_audio_stream", 100) - defer source.UnsubscribeAudio("test_audio_stream") - - sampleCount := 0 - logger.Info("🧪 Test audio streaming started") - - for { - select { - case <-r.Context().Done(): - logger.Info("🧪 Test audio stream cancelled") - return - case sample, ok := <-audioCh: - if !ok { - logger.Info("🧪 Test audio channel closed") - return - } - - // Skip empty samples just like WebRTC does - if sample.Data == nil || len(sample.Data) == 0 { - continue - } - - sampleCount++ - - // Log first few samples with detailed analysis - if sampleCount <= 10 { - logger.Info("🧪 Test audio sample", "count", sampleCount, "size", len(sample.Data), "pts", sample.PTS) - - // Show hex dump for first few samples - if sampleCount <= 3 && len(sample.Data) > 0 { - hexData := "" - dataLen := min(len(sample.Data), 16) - for i, b := range sample.Data[:dataLen] { - if i > 0 { - hexData += " " - } - hexData += fmt.Sprintf("%02x", b) - } - logger.Info("🧪 Sample hex", "count", sampleCount, "hex", hexData) - - // Analyze Opus frame structure - if len(sample.Data) > 0 { - toc := sample.Data[0] - config := (toc >> 3) & 0x1F - stereo := (toc >> 2) & 0x1 - frameCount := toc & 0x3 - logger.Info("🧪 Opus analysis", "count", sampleCount, "toc", fmt.Sprintf("0x%02x", toc), "config", config, "stereo", stereo, "frames", frameCount) - } - } - } - - // Write raw data exactly like WebRTC does - if _, err := w.Write(sample.Data); err != nil { - logger.Error("❌ Failed to write test audio data", "error", err) - return - } - - 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 + }, } diff --git a/packages/cli/internal/server/handlers/streaming_test.go b/packages/cli/internal/server/handlers/streaming_test.go new file mode 100644 index 00000000..d30d651d --- /dev/null +++ b/packages/cli/internal/server/handlers/streaming_test.go @@ -0,0 +1,334 @@ +package handlers + +import ( + "io/fs" + "net/http/httptest" + "testing" + "time" +) + +// MockServerService for testing +type MockServerService struct { + bridges map[string]interface{} +} + +// Status and info +func (m *MockServerService) IsRunning() bool { return true } +func (m *MockServerService) GetPort() int { return 8080 } +func (m *MockServerService) GetUptime() time.Duration { return time.Hour } +func (m *MockServerService) GetBuildID() string { return "test-build" } +func (m *MockServerService) GetVersion() string { return "1.0.0" } + +// Services status +func (m *MockServerService) IsADBExposeRunning() bool { return false } + +// Bridge management +func (m *MockServerService) ListBridges() []string { + var result []string + for k := range m.bridges { + result = append(result, k) + } + return result +} + +func (m *MockServerService) GetBridge(deviceSerial string) (Bridge, bool) { + _, exists := m.bridges[deviceSerial] + if !exists { + return nil, false + } + // Convert to Bridge interface - this is a mock so we'll return a simple implementation + return &MockBridge{}, true +} + +func (m *MockServerService) CreateBridge(deviceSerial string) error { + // Mock implementation - create a simple bridge + bridge := make(map[string]interface{}) + m.bridges[deviceSerial] = bridge + return nil +} + +func (m *MockServerService) RemoveBridge(deviceSerial string) { + delete(m.bridges, deviceSerial) +} + +// Static file serving +func (m *MockServerService) GetStaticFS() fs.FS { return nil } +func (m *MockServerService) FindLiveViewStaticPath() string { return "/static/live-view" } +func (m *MockServerService) FindStaticPath() string { return "/static" } + +// Server lifecycle +func (m *MockServerService) Stop() error { return nil } + +// ADB Expose methods +func (m *MockServerService) StartPortForward(boxID string, localPorts, remotePorts []int) error { + return nil +} +func (m *MockServerService) StopPortForward(boxID string) error { return nil } +func (m *MockServerService) ListPortForwards() interface{} { return nil } + +func NewMockServerService() *MockServerService { + return &MockServerService{ + bridges: make(map[string]interface{}), + } +} + +func (m *MockServerService) AddBridge(deviceSerial string, bridge interface{}) { + m.bridges[deviceSerial] = bridge +} + +// MockBridge implements the Bridge interface for testing +type MockBridge struct{} + +func (m *MockBridge) HandleTouchEvent(msg map[string]interface{}) {} +func (m *MockBridge) HandleKeyEvent(msg map[string]interface{}) {} +func (m *MockBridge) HandleScrollEvent(msg map[string]interface{}) {} + +// TestStreamingHandlersCreation tests the creation of streaming handlers +func TestStreamingHandlersCreation(t *testing.T) { + handlers := NewStreamingHandlers() + if handlers == nil { + t.Fatal("Expected handlers to be created") + } + + // Test setting server service + mockService := NewMockServerService() + handlers.SetServerService(mockService) + if handlers.serverService == nil { + t.Error("Expected server service to be set") + } + + // Test setting path prefix + handlers.SetPathPrefix("/api") + if handlers.pathPrefix != "/api" { + t.Error("Expected path prefix to be set") + } +} + +// TestUtilityFunctions tests utility functions +func TestUtilityFunctions(t *testing.T) { + // Test isValidDeviceSerial + validSerials := []string{"device123", "abc-def_123", "test.device", "DEVICE-456", "device.789", "device_abc"} + invalidSerials := []string{"", "a", "device with spaces", "device@invalid", "device#invalid", "device@123", "device/123"} + + for _, serial := range validSerials { + if !isValidDeviceSerial(serial) { + t.Errorf("Expected %s to be valid", serial) + } + } + + for _, serial := range invalidSerials { + if isValidDeviceSerial(serial) { + t.Errorf("Expected %s to be invalid", serial) + } + } + +} + +// TestHandleVideoStream tests video stream handling +func TestHandleVideoStream(t *testing.T) { + handlers := NewStreamingHandlers() + + tests := []struct { + path string + expectedStatus int + description string + }{ + {"/stream/video/", 400, "Missing device serial"}, + {"/stream/video/device123?mode=invalid", 400, "Invalid mode"}, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + req := httptest.NewRequest("GET", test.path, nil) + w := httptest.NewRecorder() + + handlers.HandleVideoStream(w, req) + + if w.Code != test.expectedStatus { + t.Errorf("Expected status %d, got %d", test.expectedStatus, w.Code) + } + }) + } + + // Note: Tests that require actual device connection are skipped in unit tests + // They should be run in integration tests with real devices +} + +// TestHandleAudioStream tests audio stream handling +func TestHandleAudioStream(t *testing.T) { + handlers := NewStreamingHandlers() + + tests := []struct { + path string + expectedStatus int + description string + }{ + {"/stream/audio/", 400, "Missing device serial"}, + {"/stream/audio/device123?codec=invalid", 400, "Invalid codec"}, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + req := httptest.NewRequest("GET", test.path, nil) + w := httptest.NewRecorder() + + handlers.HandleAudioStream(w, req) + + if w.Code != test.expectedStatus { + t.Errorf("Expected status %d, got %d", test.expectedStatus, w.Code) + } + }) + } + + // Note: Tests that require actual device connection are skipped in unit tests + // They should be run in integration tests with real devices +} + +// TestHandleControlWebSocket tests control WebSocket handling +func TestHandleControlWebSocket(t *testing.T) { + handlers := NewStreamingHandlers() + + tests := []struct { + path string + expectedStatus int + description string + }{ + {"/stream/control/device123", 400, "Valid control WebSocket request (will fail without upgrade)"}, + {"/stream/control/", 400, "Missing device serial"}, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + req := httptest.NewRequest("GET", test.path, nil) + w := httptest.NewRecorder() + + handlers.HandleControlWebSocket(w, req) + + if w.Code != test.expectedStatus { + t.Errorf("Expected status %d, got %d", test.expectedStatus, w.Code) + } + }) + } +} + +// TestHandleStreamInfo tests stream info handling +func TestHandleStreamInfo(t *testing.T) { + handlers := NewStreamingHandlers() + + tests := []struct { + path string + expectedStatus int + description string + }{ + {"/stream/info?device=device123", 200, "Valid stream info request"}, + {"/stream/info", 400, "Missing device parameter"}, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + req := httptest.NewRequest("GET", test.path, nil) + w := httptest.NewRecorder() + + handlers.HandleStreamInfo(w, req) + + if w.Code != test.expectedStatus { + t.Errorf("Expected status %d, got %d", test.expectedStatus, w.Code) + } + }) + } +} + +// TestHandleStreamConnect tests stream connect handling +func TestHandleStreamConnect(t *testing.T) { + handlers := NewStreamingHandlers() + + tests := []struct { + path string + method string + expectedStatus int + description string + }{ + {"/api/stream/device123/connect", "POST", 200, "Valid connect request"}, + {"/api/stream/device123/disconnect", "POST", 200, "Valid disconnect request"}, + {"/api/stream/device123/connect", "GET", 405, "Invalid method for connect"}, + {"/api/stream/device123/invalid", "POST", 400, "Invalid action"}, + {"/api/stream/", "POST", 400, "Invalid URL format"}, + } + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + req := httptest.NewRequest(test.method, test.path, nil) + w := httptest.NewRecorder() + + handlers.HandleStreamConnect(w, req) + + if w.Code != test.expectedStatus { + t.Errorf("Expected status %d, got %d", test.expectedStatus, w.Code) + } + }) + } +} + +// TestBuildURL tests URL building +func TestBuildURL(t *testing.T) { + handlers := NewStreamingHandlers() + + // Test without prefix + url := handlers.buildURL("/stream/video/device123") + if url != "/stream/video/device123" { + t.Errorf("Expected /stream/video/device123, got %s", url) + } + + // Test with prefix + handlers.SetPathPrefix("/api") + url = handlers.buildURL("/stream/video/device123") + if url != "/api/stream/video/device123" { + t.Errorf("Expected /api/stream/video/device123, got %s", url) + } +} + +// TestWebSocketUpgrader tests WebSocket upgrader configuration +func TestWebSocketUpgrader(t *testing.T) { + // Test that upgrader is configured correctly + if controlUpgrader.CheckOrigin == nil { + t.Error("Expected CheckOrigin to be set") + } + + // Test CheckOrigin function + req := httptest.NewRequest("GET", "/stream/control/device123", nil) + if !controlUpgrader.CheckOrigin(req) { + t.Error("Expected CheckOrigin to return true") + } +} + +// Benchmark tests for performance +func BenchmarkHandleVideoStream(b *testing.B) { + handlers := NewStreamingHandlers() + req := httptest.NewRequest("GET", "/stream/video/device123?mode=h264", nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + w := httptest.NewRecorder() + handlers.HandleVideoStream(w, req) + } +} + +func BenchmarkHandleAudioStream(b *testing.B) { + handlers := NewStreamingHandlers() + req := httptest.NewRequest("GET", "/stream/audio/device123?codec=opus", nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + w := httptest.NewRecorder() + handlers.HandleAudioStream(w, req) + } +} + +func BenchmarkUtilityFunctions(b *testing.B) { + serial := "device123" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + isValidDeviceSerial(serial) + } +} diff --git a/packages/cli/internal/server/handlers/streaming_utils.go b/packages/cli/internal/server/handlers/streaming_utils.go new file mode 100644 index 00000000..1742b189 --- /dev/null +++ b/packages/cli/internal/server/handlers/streaming_utils.go @@ -0,0 +1,31 @@ +package handlers + +// min returns the smaller of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// Helper function to validate device serial format +func isValidDeviceSerial(serial string) bool { + if serial == "" { + return false + } + + // Basic validation - should be alphanumeric with possible special chars + if len(serial) < 3 || len(serial) > 64 { + return false + } + + // Allow alphanumeric, dots, dashes, underscores + for _, c := range serial { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_') { + return false + } + } + + return true +} diff --git a/packages/cli/internal/server/handlers/webrtc.go b/packages/cli/internal/server/handlers/webrtc.go index ab35551a..09fef348 100644 --- a/packages/cli/internal/server/handlers/webrtc.go +++ b/packages/cli/internal/server/handlers/webrtc.go @@ -60,16 +60,16 @@ func (h *WebRTCHandlers) HandleWebRTCSignaling(conn *websocket.Conn, deviceSeria switch msgType { case "offer": - h.handleOffer(conn, msg, deviceSerial) + h.HandleOffer(conn, msg, deviceSerial) case "answer": - h.handleAnswer(conn, msg, deviceSerial) + h.HandleAnswer(conn, msg, deviceSerial) case "ice-candidate": - h.handleIceCandidate(conn, msg, deviceSerial) + h.HandleIceCandidate(conn, msg, deviceSerial) case "ping": - h.handlePing(conn, msg) + h.HandlePing(conn, msg) default: log.Printf("Unknown WebRTC signaling message type: %s", msgType) @@ -77,8 +77,8 @@ func (h *WebRTCHandlers) HandleWebRTCSignaling(conn *websocket.Conn, deviceSeria } } -// handleOffer processes WebRTC offer messages -func (h *WebRTCHandlers) handleOffer(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { +// HandleOffer processes WebRTC offer messages +func (h *WebRTCHandlers) HandleOffer(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { log.Printf("WebRTC offer received: device=%s", deviceSerial) // Extract the offer SDP from the message @@ -231,8 +231,8 @@ func (h *WebRTCHandlers) handleOffer(conn *websocket.Conn, msg map[string]interf }() } -// handleAnswer processes WebRTC answer messages -func (h *WebRTCHandlers) handleAnswer(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { +// HandleAnswer processes WebRTC answer messages +func (h *WebRTCHandlers) HandleAnswer(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { log.Printf("WebRTC answer received: device=%s", deviceSerial) // TODO: Process WebRTC answer from client @@ -240,8 +240,8 @@ func (h *WebRTCHandlers) handleAnswer(conn *websocket.Conn, msg map[string]inter log.Printf("WebRTC answer processing not yet implemented") } -// handleIceCandidate processes WebRTC ICE candidate messages -func (h *WebRTCHandlers) handleIceCandidate(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { +// HandleIceCandidate processes WebRTC ICE candidate messages +func (h *WebRTCHandlers) HandleIceCandidate(conn *websocket.Conn, msg map[string]interface{}, deviceSerial string) { log.Printf("WebRTC ICE candidate received: device=%s", deviceSerial) // Get WebRTC bridge for this device @@ -309,7 +309,7 @@ func (h *WebRTCHandlers) handleIceCandidate(conn *websocket.Conn, msg map[string } // handlePing handles ping messages for latency measurement -func (h *WebRTCHandlers) handlePing(conn *websocket.Conn, msg map[string]interface{}) { +func (h *WebRTCHandlers) HandlePing(conn *websocket.Conn, msg map[string]interface{}) { pongMsg := map[string]interface{}{ "type": "pong", } diff --git a/packages/cli/internal/server/router/streaming.go b/packages/cli/internal/server/router/streaming.go index 8a9c913d..6deda70d 100644 --- a/packages/cli/internal/server/router/streaming.go +++ b/packages/cli/internal/server/router/streaming.go @@ -2,6 +2,7 @@ package router import ( "net/http" + "github.com/babelcloud/gbox/packages/cli/internal/server/handlers" ) @@ -33,9 +34,6 @@ func (r *StreamingRouter) RegisterRoutes(mux *http.ServeMux, server interface{}) // Audio streaming endpoints mux.HandleFunc("/api/stream/audio/", r.transformer.TransformHandler(r.handlers.HandleAudioStream)) - // WebSocket video streaming (consolidated from /ws/video/ and /stream/ws/) - mux.HandleFunc("/api/stream/video/ws/", r.transformer.TransformHandler(r.handlers.HandleVideoWebSocket)) - // Device control WebSocket mux.HandleFunc("/api/stream/control/", r.transformer.TransformHandler(r.handlers.HandleControlWebSocket)) @@ -44,9 +42,10 @@ func (r *StreamingRouter) RegisterRoutes(mux *http.ServeMux, server interface{}) // Stream info endpoint mux.HandleFunc("/api/stream/info", r.transformer.TransformHandler(r.handlers.HandleStreamInfo)) + } // GetPathPrefix returns the path prefix for this router func (r *StreamingRouter) GetPathPrefix() string { return "/stream" -} \ No newline at end of file +} diff --git a/packages/live-view/index.html b/packages/live-view/index.html index b84bd1de..6b01bac9 100644 --- a/packages/live-view/index.html +++ b/packages/live-view/index.html @@ -4,6 +4,7 @@ GBOX Live View + -
- + + \ No newline at end of file diff --git a/packages/live-view/package.json b/packages/live-view/package.json index f085f1b5..06aa6fdb 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -21,6 +21,7 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "build-sdk": "vite build --config vite.config.sdk.ts", "prepublishOnly": "npm run build" }, "peerDependencies": { diff --git a/packages/live-view/sdk/AndroidLiveviewComponent.tsx b/packages/live-view/sdk/AndroidLiveviewComponent.tsx new file mode 100644 index 00000000..465a6bc0 --- /dev/null +++ b/packages/live-view/sdk/AndroidLiveviewComponent.tsx @@ -0,0 +1,259 @@ +import { useEffect, useRef, useState } from "react"; +import { ConnectionState, Device, Stats } from "../src/types"; +import { + WebRTCClient, + H264Client, + MP4Client, + useKeyboardHandler, + useClipboardHandler, + useMouseHandler, + useTouchHandler, + useClickHandler, + useWheelHandler, + useControlHandler, +} from "../src"; +import styles from "../src/components/AndroidLiveView.module.css"; +import React from "react"; +import { ControlButtons } from "../src/components/ControlButtons"; + +export interface AndroidLiveviewComponentProps { + onConnectionStateChange?: (state: ConnectionState, message?: string) => void; + onError?: (error: Error) => void; + onStatsUpdate?: (stats: Stats) => void; + onConnect?: (device: Device) => void; + onDisconnect?: () => void; + connectaParams: { + deviceSerial: string; + apiUrl: string; + wsUrl: string; + }; +} + +export function AndroidLiveviewComponent(props: AndroidLiveviewComponentProps) { + const { + onConnectionStateChange, + onError, + onStatsUpdate, + onConnect, + onDisconnect, + connectaParams, + } = props; + + const clientRef = useRef(null); + const videoWrapperRef = useRef(null); + const containerRef = useRef(null); + const touchIndicatorRef = useRef(null); + + + const [connectionStatus, setConnectionStatus] = useState(""); + const [isConnected, setIsConnected] = useState(false); + const [stats, setStats] = useState({ + fps: 0, + resolution: "", + latency: 0, + }); + const [keyboardCaptureEnabled] = useState(true); + const [touchIndicator, setTouchIndicator] = useState<{ + visible: boolean; + x: number; + y: number; + dragging: boolean; + }>({ + visible: false, + x: 0, + y: 0, + dragging: false, + }); + + const clipboardHandler = useClipboardHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + onError, + }); + + const keyboardHandler = useKeyboardHandler({ + client: clientRef.current, + enabled: isConnected, + keyboardCaptureEnabled, + isConnected, + onClipboardPaste: clipboardHandler.handleClipboardPaste, + onClipboardCopy: clipboardHandler.handleClipboardCopy, + }); + + const touchHandler = useTouchHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + }); + + const wheelHandler = useWheelHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + }); + + // const controlHandler = useControlHandler({ + // client: clientRef.current, + // enabled: isConnected, + // isConnected, + // }); + + // const handleControlAction = React.useCallback((action: string) => { + // controlHandler.handleControlAction(action); + // }, [controlHandler]); + + const { deviceSerial, apiUrl, wsUrl } = connectaParams; + + useEffect(() => { + console.log("===="); + clientRef.current = new MP4Client({ + onConnectionStateChange: (state: ConnectionState, message?: string) => { + console.log("====", state, message); + setConnectionStatus(message || state); + setIsConnected(state === "connected"); + if (state === "connected") { + clientRef.current?.connect(deviceSerial, apiUrl, wsUrl); + } else if (state === "disconnected") { + clientRef.current?.disconnect(); + } + }, + onError: (error: Error) => { + onError?.(error); + }, + onStatsUpdate: (stats: Stats) => { + setStats(stats); + }, + }); + + setTimeout(() => { + clientRef.current?.connect(deviceSerial, apiUrl, wsUrl); + }, 1000); + }, []); + + const mouseHandler = useMouseHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + }); + + // Touch indicator handlers - calculate position relative to viewport + const showTouchIndicator = React.useCallback( + (x: number, y: number, dragging: boolean = false) => { + setTouchIndicator({ visible: true, x, y, dragging }); + }, + [] + ); + + const hideTouchIndicator = React.useCallback(() => { + setTouchIndicator((prev) => ({ ...prev, visible: false })); + }, []); + + const updateTouchIndicator = React.useCallback( + (x: number, y: number, dragging: boolean = false) => { + setTouchIndicator((prev) => ({ ...prev, x, y, dragging })); + }, + [] + ); + + const clickHandler = useClickHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + }); + + // const handleIMESwitch = React.useCallback(() => { + // controlHandler.handleIMESwitch(); + // }, [controlHandler]) + + return ( +
+
+ {/* Video Area - Simplified structure */} +
+
{ + mouseHandler.handleMouseDown(e); + // Use clientX/Y directly for fixed positioning + showTouchIndicator(e.clientX, e.clientY, false); + }} + onMouseUp={(e) => { + mouseHandler.handleMouseUp(e); + hideTouchIndicator(); + }} + onMouseMove={(e) => { + mouseHandler.handleMouseMove(e); + if (touchIndicator.visible) { + // Use clientX/Y directly for fixed positioning + updateTouchIndicator(e.clientX, e.clientY, true); + } + }} + onMouseLeave={(e) => { + mouseHandler.handleMouseLeave(e); + hideTouchIndicator(); + }} + onTouchStart={(e) => { + touchHandler.handleTouchStart(e); + const touch = e.touches[0]; + // Use clientX/Y directly for fixed positioning + showTouchIndicator(touch.clientX, touch.clientY, false); + }} + onTouchEnd={(e) => { + touchHandler.handleTouchEnd(e); + hideTouchIndicator(); + }} + onTouchMove={(e) => { + touchHandler.handleTouchMove(e); + if (touchIndicator.visible) { + const touch = e.touches[0]; + // Use clientX/Y directly for fixed positioning + updateTouchIndicator(touch.clientX, touch.clientY, true); + } + }} + onClick={clickHandler.handleClick} + onWheel={ + wheelHandler.handleWheel as unknown as React.WheelEventHandler + } + tabIndex={0} + > +
+
+ + {/* Android Control Buttons */} + {/* {showAndroidControls && ( */} + {/* */} + {/* )} */} +
+ + {/* Stats */} +
+
+
Resolution: {stats.resolution || "N/A"}
+
FPS: {stats.fps || 0}
+
Latency: {stats.latency || 0}ms
+
ConnectionStatus: {connectionStatus}
+
+
+
+
+
+ ); +} diff --git a/packages/live-view/sdk/index.ts b/packages/live-view/sdk/index.ts new file mode 100644 index 00000000..3514e08c --- /dev/null +++ b/packages/live-view/sdk/index.ts @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { AndroidLiveviewComponent, AndroidLiveviewComponentProps } from "./AndroidLiveviewComponent"; + + +export function createAndroidLiveView(mount: HTMLElement, props: AndroidLiveviewComponentProps) { + const root = (ReactDOM as any).createRoot(mount); + root.render(React.createElement(AndroidLiveviewComponent, props)); + return root; +} diff --git a/packages/live-view/sdk/use-sdk-entry.ts b/packages/live-view/sdk/use-sdk-entry.ts new file mode 100644 index 00000000..0fb2cac4 --- /dev/null +++ b/packages/live-view/sdk/use-sdk-entry.ts @@ -0,0 +1,28 @@ +import { createAndroidLiveView } from "./index"; + +// 用于测试 +async function main() { + const div = document.createElement("div"); + div.id = "video-container"; + div.style = "width: 100vw; height: 100vh;"; + document.body.appendChild(div); + + createAndroidLiveView(div, { + onConnectionStateChange: (state, message) => { + console.log("连接状态变化:", state, message); + }, + onError: (error) => { + console.error("发生错误:", error); + }, + onStatsUpdate: (stats) => { + console.log("统计信息:", stats); + }, + connectaParams: { + deviceSerial: "68afd15", + apiUrl: "http://localhost:3000/api", + wsUrl: "ws://localhost:3000", + } + }) +} + +main(); diff --git a/packages/live-view/src/components/AndroidLiveView.tsx b/packages/live-view/src/components/AndroidLiveView.tsx index 6e069a73..d80e90e9 100644 --- a/packages/live-view/src/components/AndroidLiveView.tsx +++ b/packages/live-view/src/components/AndroidLiveView.tsx @@ -289,6 +289,7 @@ export const AndroidLiveView: React.FC = ({ setConnectionStatus('Connecting...'); if (clientRef.current instanceof MP4Client) { + console.log("=====", device.serial, apiUrl, wsUrl, forceReconnect) // MP4Client needs wsUrl for control WebSocket connection await clientRef.current.connect(device.serial, apiUrl, wsUrl, forceReconnect); } else { diff --git a/packages/live-view/src/lib/muxed-client.ts b/packages/live-view/src/lib/muxed-client.ts index 58c72ec9..2b0c0c59 100644 --- a/packages/live-view/src/lib/muxed-client.ts +++ b/packages/live-view/src/lib/muxed-client.ts @@ -1051,6 +1051,11 @@ export class MP4Client implements ControlClient { ); this.controlWs = new WebSocket(controlWsUrl); + // set timeout to avoid WebSocket connection failure + setInterval(() => { + resolve(); + }, 1000); + this.controlWs.onopen = () => { console.log("[MP4Client] Control WebSocket connected successfully"); this.isControlConnectedFlag = true; diff --git a/packages/live-view/tsconfig.json b/packages/live-view/tsconfig.json index 80b268e7..83411045 100644 --- a/packages/live-view/tsconfig.json +++ b/packages/live-view/tsconfig.json @@ -31,7 +31,8 @@ "forceConsistentCasingInFileNames": true }, "include": [ - "src" + "src", + "sdk" ], "exclude": [ "node_modules", diff --git a/packages/live-view/vite.config.sdk.ts b/packages/live-view/vite.config.sdk.ts new file mode 100644 index 00000000..3dd3ead0 --- /dev/null +++ b/packages/live-view/vite.config.sdk.ts @@ -0,0 +1,54 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import dts from "vite-plugin-dts"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react(), + dts({ + insertTypesEntry: true, + include: ['sdk/**/*'], + exclude: ['**/*.test.*', '**/*.spec.*'] + }) + ], + build: { + outDir: "dist", + lib: { + entry: "sdk/index.ts", + name: "gbox-live-view", + fileName: (format) => `index.${format}.js`, + formats: ['es', 'umd'] + }, + rollupOptions: { + // 不要把 peerDependencies 打包进去 + external: ['react', 'react-dom'], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM' + } + } + }, + cssCodeSplit: false + // sourcemap: true + }, + server: { + port: 3000, + proxy: { + "/api": { + target: "http://localhost:29888", + changeOrigin: true, + }, + "/ws": { + target: "ws://localhost:29888", + ws: true, + changeOrigin: true, + }, + "/stream": { + target: "http://localhost:29888", + changeOrigin: true, + }, + }, + }, +}); From 09081fb52ba1e5e890f981d94d89694e2631a5b6 Mon Sep 17 00:00:00 2001 From: zhangze <1183460943@qq.com> Date: Fri, 10 Oct 2025 10:27:57 +0800 Subject: [PATCH 16/34] feat: add publish npm --- .github/workflows/publish-live-view.yml | 58 +++++++++++++++++++++++++ packages/live-view/package.json | 16 +++++-- packages/live-view/vite.config.sdk.ts | 12 ++--- 3 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/publish-live-view.yml diff --git a/.github/workflows/publish-live-view.yml b/.github/workflows/publish-live-view.yml new file mode 100644 index 00000000..a4a61c88 --- /dev/null +++ b/.github/workflows/publish-live-view.yml @@ -0,0 +1,58 @@ +name: Publish Live View Package + +on: + push: + branches: [ zhangze/live-view ] # 监听当前分支 + paths: + - 'packages/live-view/**' # 只监听live-view目录的变化 + +jobs: + publish: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + registry-url: 'https://npm.pkg.github.com' + scope: '@gbox' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.17.0 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + working-directory: packages/live-view + run: pnpm install --frozen-lockfile + + - name: Build SDK + working-directory: packages/live-view + run: npm run build:sdk + + - name: Publish to GitHub Packages + working-directory: packages/live-view + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/live-view/package.json b/packages/live-view/package.json index 06aa6fdb..3a498914 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -3,18 +3,28 @@ "version": "0.1.0", "type": "module", "description": "Live view component for Android device streaming", - "main": "dist/index.js", - "module": "dist/index.esm.js", + "main": "dist/index.umd.js", + "module": "dist/index.es.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.es.js", + "require": "./dist/index.umd.js", + "types": "./dist/index.d.ts" + }, + "./style.css": "./dist/style.css" + }, "files": [ "dist", - "static" + "static", + "README.md" ], "scripts": { "dev": "vite", "build": "npm run build:component && npm run build:static", "build:component": "rollup -c", "build:static": "vite build", + "build:sdk": "vite build --config vite.config.sdk.ts", "preview": "vite preview", "type-check": "tsc --noEmit", "lint": "eslint src", diff --git a/packages/live-view/vite.config.sdk.ts b/packages/live-view/vite.config.sdk.ts index 3dd3ead0..7c067330 100644 --- a/packages/live-view/vite.config.sdk.ts +++ b/packages/live-view/vite.config.sdk.ts @@ -1,16 +1,16 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -import dts from "vite-plugin-dts"; +// import dts from "vite-plugin-dts"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [ react(), - dts({ - insertTypesEntry: true, - include: ['sdk/**/*'], - exclude: ['**/*.test.*', '**/*.spec.*'] - }) + // dts({ + // insertTypesEntry: true, + // include: ['sdk/**/*'], + // exclude: ['**/*.test.*', '**/*.spec.*'] + // }) ], build: { outDir: "dist", From aff5d029cd62227ddae896cb2583df2240ac9a18 Mon Sep 17 00:00:00 2001 From: zhangze <1183460943@qq.com> Date: Fri, 10 Oct 2025 10:46:29 +0800 Subject: [PATCH 17/34] feat: add publish npm --- packages/live-view/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/live-view/package.json b/packages/live-view/package.json index 3a498914..b358426c 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -1,5 +1,5 @@ { - "name": "@gbox/live-view", + "name": "@babelcloud/live-view", "version": "0.1.0", "type": "module", "description": "Live view component for Android device streaming", @@ -71,7 +71,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/gbox-ai/gbox.git", + "url": "https://github.com/babelcloud/gbox.git", "directory": "packages/live-view" }, "keywords": [ From 1fd62dd4ac8de3bd22a8174e804858596915b8b0 Mon Sep 17 00:00:00 2001 From: zhangze <1183460943@qq.com> Date: Fri, 10 Oct 2025 10:56:27 +0800 Subject: [PATCH 18/34] feat: add publish npm --- packages/live-view/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/live-view/package.json b/packages/live-view/package.json index b358426c..233cbb57 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -1,6 +1,6 @@ { "name": "@babelcloud/live-view", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "Live view component for Android device streaming", "main": "dist/index.umd.js", @@ -16,7 +16,6 @@ }, "files": [ "dist", - "static", "README.md" ], "scripts": { From f2507de7d68adbc347b0ed59c051480083a78fc0 Mon Sep 17 00:00:00 2001 From: zhangze <1183460943@qq.com> Date: Fri, 10 Oct 2025 11:14:37 +0800 Subject: [PATCH 19/34] feat: add publish npm --- packages/live-view/package.json | 2 +- packages/live-view/sdk/index.ts | 12 +----- packages/live-view/sdk/use-sdk-entry.ts | 50 ++++++++++++------------- 3 files changed, 28 insertions(+), 36 deletions(-) diff --git a/packages/live-view/package.json b/packages/live-view/package.json index 233cbb57..a46584e2 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -1,6 +1,6 @@ { "name": "@babelcloud/live-view", - "version": "0.2.0", + "version": "0.3.0", "type": "module", "description": "Live view component for Android device streaming", "main": "dist/index.umd.js", diff --git a/packages/live-view/sdk/index.ts b/packages/live-view/sdk/index.ts index 3514e08c..208b7b23 100644 --- a/packages/live-view/sdk/index.ts +++ b/packages/live-view/sdk/index.ts @@ -1,10 +1,2 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { AndroidLiveviewComponent, AndroidLiveviewComponentProps } from "./AndroidLiveviewComponent"; - - -export function createAndroidLiveView(mount: HTMLElement, props: AndroidLiveviewComponentProps) { - const root = (ReactDOM as any).createRoot(mount); - root.render(React.createElement(AndroidLiveviewComponent, props)); - return root; -} +export { AndroidLiveviewComponent } from "./AndroidLiveviewComponent"; +export type { AndroidLiveviewComponentProps } from "./AndroidLiveviewComponent"; diff --git a/packages/live-view/sdk/use-sdk-entry.ts b/packages/live-view/sdk/use-sdk-entry.ts index 0fb2cac4..e44a3405 100644 --- a/packages/live-view/sdk/use-sdk-entry.ts +++ b/packages/live-view/sdk/use-sdk-entry.ts @@ -1,28 +1,28 @@ -import { createAndroidLiveView } from "./index"; +// import { createAndroidLiveView } from "./index"; -// 用于测试 -async function main() { - const div = document.createElement("div"); - div.id = "video-container"; - div.style = "width: 100vw; height: 100vh;"; - document.body.appendChild(div); +// // 用于测试 +// async function main() { +// const div = document.createElement("div"); +// div.id = "video-container"; +// div.style = "width: 100vw; height: 100vh;"; +// document.body.appendChild(div); - createAndroidLiveView(div, { - onConnectionStateChange: (state, message) => { - console.log("连接状态变化:", state, message); - }, - onError: (error) => { - console.error("发生错误:", error); - }, - onStatsUpdate: (stats) => { - console.log("统计信息:", stats); - }, - connectaParams: { - deviceSerial: "68afd15", - apiUrl: "http://localhost:3000/api", - wsUrl: "ws://localhost:3000", - } - }) -} +// createAndroidLiveView(div, { +// onConnectionStateChange: (state, message) => { +// console.log("连接状态变化:", state, message); +// }, +// onError: (error) => { +// console.error("发生错误:", error); +// }, +// onStatsUpdate: (stats) => { +// console.log("统计信息:", stats); +// }, +// connectaParams: { +// deviceSerial: "68afd15", +// apiUrl: "http://localhost:3000/api", +// wsUrl: "ws://localhost:3000", +// } +// }) +// } -main(); +// main(); From fb2517fe0287110f5eff56f4abd838d6302deec4 Mon Sep 17 00:00:00 2001 From: zhangze <1183460943@qq.com> Date: Fri, 10 Oct 2025 11:26:56 +0800 Subject: [PATCH 20/34] feat: add publish npm --- .github/workflows/publish-live-view.yml | 8 -------- packages/live-view/package.json | 2 +- packages/live-view/sdk/index.ts | 5 +++-- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish-live-view.yml b/.github/workflows/publish-live-view.yml index a4a61c88..69c1c72a 100644 --- a/.github/workflows/publish-live-view.yml +++ b/.github/workflows/publish-live-view.yml @@ -34,14 +34,6 @@ jobs: shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - name: Install dependencies working-directory: packages/live-view diff --git a/packages/live-view/package.json b/packages/live-view/package.json index a46584e2..ef85e88b 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -1,6 +1,6 @@ { "name": "@babelcloud/live-view", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "description": "Live view component for Android device streaming", "main": "dist/index.umd.js", diff --git a/packages/live-view/sdk/index.ts b/packages/live-view/sdk/index.ts index 208b7b23..5f12edaf 100644 --- a/packages/live-view/sdk/index.ts +++ b/packages/live-view/sdk/index.ts @@ -1,2 +1,3 @@ -export { AndroidLiveviewComponent } from "./AndroidLiveviewComponent"; -export type { AndroidLiveviewComponentProps } from "./AndroidLiveviewComponent"; +import { AndroidLiveviewComponent } from "./AndroidLiveviewComponent"; +export default AndroidLiveviewComponent; +export type { AndroidLiveviewComponentProps } from "./AndroidLiveviewComponent"; \ No newline at end of file From 9f61121df0d602c53482b7aed65a8041d18501ee Mon Sep 17 00:00:00 2001 From: zhangze <1183460943@qq.com> Date: Fri, 10 Oct 2025 13:38:16 +0800 Subject: [PATCH 21/34] feat: add publish npm --- packages/live-view/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/live-view/package.json b/packages/live-view/package.json index ef85e88b..b9c38146 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -30,8 +30,7 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "build-sdk": "vite build --config vite.config.sdk.ts", - "prepublishOnly": "npm run build" + "build-sdk": "vite build --config vite.config.sdk.ts" }, "peerDependencies": { "react": "^18.0.0", From 2df41363c1f470df5f977912d49c7dfd3a80904d Mon Sep 17 00:00:00 2001 From: zhangze <1183460943@qq.com> Date: Fri, 10 Oct 2025 14:02:00 +0800 Subject: [PATCH 22/34] feat: add publish npm --- packages/live-view/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/live-view/package.json b/packages/live-view/package.json index b9c38146..4a9dc2ce 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -1,6 +1,6 @@ { "name": "@babelcloud/live-view", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "description": "Live view component for Android device streaming", "main": "dist/index.umd.js", From f105a84d8df90d06d0c0317607af4350abcb13b4 Mon Sep 17 00:00:00 2001 From: zhangze <1183460943@qq.com> Date: Fri, 10 Oct 2025 14:15:50 +0800 Subject: [PATCH 23/34] feat: publish new npm version --- packages/live-view/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/live-view/package.json b/packages/live-view/package.json index 4a9dc2ce..f1dfc943 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -1,6 +1,6 @@ { "name": "@babelcloud/live-view", - "version": "0.5.0", + "version": "0.7.0", "type": "module", "description": "Live view component for Android device streaming", "main": "dist/index.umd.js", @@ -20,9 +20,9 @@ ], "scripts": { "dev": "vite", - "build": "npm run build:component && npm run build:static", + "build": "vite build --config vite.config.sdk.ts", "build:component": "rollup -c", - "build:static": "vite build", + "build:static": "vite build --config vite.config.sdk.ts", "build:sdk": "vite build --config vite.config.sdk.ts", "preview": "vite preview", "type-check": "tsc --noEmit", From 6788a739d1e91f2050e14ce06f5d996500b7482a Mon Sep 17 00:00:00 2001 From: zhangze <1183460943@qq.com> Date: Fri, 17 Oct 2025 15:21:34 +0800 Subject: [PATCH 24/34] feat: add test live view entry --- packages/live-view/index.html | 6 ++--- packages/live-view/sdk/use-sdk-entry.ts | 28 ------------------------ packages/live-view/sdk/use-sdk-entry.tsx | 17 ++++++++++++++ 3 files changed, 20 insertions(+), 31 deletions(-) delete mode 100644 packages/live-view/sdk/use-sdk-entry.ts create mode 100644 packages/live-view/sdk/use-sdk-entry.tsx diff --git a/packages/live-view/index.html b/packages/live-view/index.html index b34248eb..5046419e 100644 --- a/packages/live-view/index.html +++ b/packages/live-view/index.html @@ -32,8 +32,8 @@ - - +
+ + \ No newline at end of file diff --git a/packages/live-view/sdk/use-sdk-entry.ts b/packages/live-view/sdk/use-sdk-entry.ts deleted file mode 100644 index e44a3405..00000000 --- a/packages/live-view/sdk/use-sdk-entry.ts +++ /dev/null @@ -1,28 +0,0 @@ -// import { createAndroidLiveView } from "./index"; - -// // 用于测试 -// async function main() { -// const div = document.createElement("div"); -// div.id = "video-container"; -// div.style = "width: 100vw; height: 100vh;"; -// document.body.appendChild(div); - -// createAndroidLiveView(div, { -// onConnectionStateChange: (state, message) => { -// console.log("连接状态变化:", state, message); -// }, -// onError: (error) => { -// console.error("发生错误:", error); -// }, -// onStatsUpdate: (stats) => { -// console.log("统计信息:", stats); -// }, -// connectaParams: { -// deviceSerial: "68afd15", -// apiUrl: "http://localhost:3000/api", -// wsUrl: "ws://localhost:3000", -// } -// }) -// } - -// main(); diff --git a/packages/live-view/sdk/use-sdk-entry.tsx b/packages/live-view/sdk/use-sdk-entry.tsx new file mode 100644 index 00000000..a6897edc --- /dev/null +++ b/packages/live-view/sdk/use-sdk-entry.tsx @@ -0,0 +1,17 @@ +import ReactDOM from "react-dom/client"; +import AndroidLiveviewComponent from "./index"; + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Root element not found"); +} + +ReactDOM.createRoot(rootElement).render( + +); From fb9e735cfc80258435faf3e3e7a0eb7e7d7d0dc2 Mon Sep 17 00:00:00 2001 From: zhangze <1183460943@qq.com> Date: Mon, 20 Oct 2025 10:12:51 +0800 Subject: [PATCH 25/34] feat: add test live view entry --- .../sdk/AndroidLiveviewComponent.tsx | 55 ++++++++----- packages/live-view/src/lib/muxed-client.ts | 79 +++++++++++++++++-- 2 files changed, 109 insertions(+), 25 deletions(-) diff --git a/packages/live-view/sdk/AndroidLiveviewComponent.tsx b/packages/live-view/sdk/AndroidLiveviewComponent.tsx index 465a6bc0..ee7a5211 100644 --- a/packages/live-view/sdk/AndroidLiveviewComponent.tsx +++ b/packages/live-view/sdk/AndroidLiveviewComponent.tsx @@ -31,10 +31,10 @@ export interface AndroidLiveviewComponentProps { export function AndroidLiveviewComponent(props: AndroidLiveviewComponentProps) { const { - onConnectionStateChange, + onConnectionStateChange: _onConnectionStateChange, onError, - onStatsUpdate, - onConnect, + onStatsUpdate: _onStatsUpdate, + onConnect: _onConnect, onDisconnect, connectaParams, } = props; @@ -53,6 +53,7 @@ export function AndroidLiveviewComponent(props: AndroidLiveviewComponentProps) { latency: 0, }); const [keyboardCaptureEnabled] = useState(true); + const [showAndroidControls, setShowAndroidControls] = useState(true); const [touchIndicator, setTouchIndicator] = useState<{ visible: boolean; x: number; @@ -93,15 +94,15 @@ export function AndroidLiveviewComponent(props: AndroidLiveviewComponentProps) { isConnected, }); - // const controlHandler = useControlHandler({ - // client: clientRef.current, - // enabled: isConnected, - // isConnected, - // }); + const controlHandler = useControlHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + }); - // const handleControlAction = React.useCallback((action: string) => { - // controlHandler.handleControlAction(action); - // }, [controlHandler]); + const handleControlAction = React.useCallback((action: string) => { + controlHandler.handleControlAction(action); + }, [controlHandler]); const { deviceSerial, apiUrl, wsUrl } = connectaParams; @@ -162,9 +163,20 @@ export function AndroidLiveviewComponent(props: AndroidLiveviewComponentProps) { isConnected, }); - // const handleIMESwitch = React.useCallback(() => { - // controlHandler.handleIMESwitch(); - // }, [controlHandler]) + const handleIMESwitch = React.useCallback(() => { + controlHandler.handleIMESwitch(); + }, [controlHandler]); + + const handleDisconnect = React.useCallback(() => { + if (clientRef.current) { + clientRef.current.disconnect(); + } + onDisconnect?.(); + }, [onDisconnect]); + + const toggleAndroidControls = React.useCallback(() => { + setShowAndroidControls(prev => !prev); + }, []); return (
@@ -228,12 +240,15 @@ export function AndroidLiveviewComponent(props: AndroidLiveviewComponentProps) {
{/* Android Control Buttons */} - {/* {showAndroidControls && ( */} - {/* */} - {/* )} */} + {showAndroidControls && ( + + )}
{/* Stats */} diff --git a/packages/live-view/src/lib/muxed-client.ts b/packages/live-view/src/lib/muxed-client.ts index 2b0c0c59..deda60ea 100644 --- a/packages/live-view/src/lib/muxed-client.ts +++ b/packages/live-view/src/lib/muxed-client.ts @@ -221,7 +221,11 @@ export class MP4Client implements ControlClient { this.videoElement = document.createElement("video"); this.videoElement.src = URL.createObjectURL(this.mediaSource); this.videoElement.autoplay = true; - this.videoElement.muted = false; // Enable audio for MP4 streams + this.videoElement.muted = true; // Start muted to bypass autoplay restrictions + this.videoElement.playsInline = true; // Enable inline playback on mobile + this.videoElement.controls = false; // Hide controls for clean UI + this.videoElement.loop = false; // Don't loop the stream + this.videoElement.preload = "auto"; // Preload video data this.videoElement.style.width = "100%"; this.videoElement.style.height = "100%"; this.videoElement.style.objectFit = "contain"; @@ -230,7 +234,19 @@ export class MP4Client implements ControlClient { this.videoElement.addEventListener("loadedmetadata", () => { if (this.videoElement) { - this.videoElement.muted = false; + // Keep muted initially to ensure autoplay works + console.log("[MP4Client] Video metadata loaded, attempting to play"); + this.videoElement.play().catch((error) => { + console.warn("[MP4Client] Initial play failed:", error); + // Try again after a short delay + setTimeout(() => { + if (this.videoElement) { + this.videoElement.play().catch((retryError) => { + console.warn("[MP4Client] Retry play failed:", retryError); + }); + } + }, 100); + }); // Update stats when metadata is loaded this.updateStats(); } @@ -239,11 +255,23 @@ export class MP4Client implements ControlClient { this.videoElement.addEventListener("canplay", () => { if (this.videoElement) { - this.videoElement.play().catch(() => {}); + console.log("[MP4Client] Video can play, ensuring playback"); + this.videoElement.play().catch((error) => { + console.warn("[MP4Client] Canplay play failed:", error); + }); } this.hasStartedPlayback = true; }); + this.videoElement.addEventListener("canplaythrough", () => { + if (this.videoElement) { + console.log("[MP4Client] Video can play through, ensuring playback"); + this.videoElement.play().catch((error) => { + console.warn("[MP4Client] Canplaythrough play failed:", error); + }); + } + }); + this.videoElement.addEventListener("waiting", () => { this.handleVideoStall(); }); @@ -252,6 +280,9 @@ export class MP4Client implements ControlClient { window.dispatchEvent(new Event("resize")); }); + // Add user interaction handler to enable audio + this.addUserInteractionHandler(); + // Add video element to the page await this.addVideoElementToPage(); @@ -259,6 +290,31 @@ export class MP4Client implements ControlClient { await this.waitForMediaSourceReady(); } + /** + * Add user interaction handler to enable audio + */ + private addUserInteractionHandler(): void { + let hasUserInteracted = false; + + const enableAudio = () => { + if (hasUserInteracted || !this.videoElement) return; + hasUserInteracted = true; + + console.log("[MP4Client] User interaction detected, enabling audio"); + this.videoElement.muted = false; + + // Remove event listeners after first interaction + document.removeEventListener("click", enableAudio); + document.removeEventListener("touchstart", enableAudio); + document.removeEventListener("keydown", enableAudio); + }; + + // Listen for user interactions + document.addEventListener("click", enableAudio, { once: true }); + document.addEventListener("touchstart", enableAudio, { once: true }); + document.addEventListener("keydown", enableAudio, { once: true }); + } + /** * Add video element to the page */ @@ -605,12 +661,18 @@ export class MP4Client implements ControlClient { try { this.sourceBuffer.appendBuffer(merged as unknown as BufferSource); if (this.videoElement && this.videoElement.paused) { - this.videoElement.play().catch(() => {}); + console.log("[MP4Client] Video paused, attempting to play after buffer append"); + this.videoElement.play().catch((error) => { + console.warn("[MP4Client] Play after buffer append failed:", error); + }); } } catch (e) { console.error("[MP4Client] appendBuffer (coalesced) failed", e); if (this.videoElement) { - this.videoElement.play().catch(() => {}); + console.log("[MP4Client] Attempting to play after append error"); + this.videoElement.play().catch((error) => { + console.warn("[MP4Client] Play after append error failed:", error); + }); } } }; @@ -641,6 +703,13 @@ export class MP4Client implements ControlClient { ) { try { this.sourceBuffer.appendBuffer(chunk as unknown as BufferSource); + // Try to play after appending data + if (this.videoElement && this.videoElement.paused) { + console.log("[MP4Client] Video paused, attempting to play after immediate append"); + this.videoElement.play().catch((error) => { + console.warn("[MP4Client] Play after immediate append failed:", error); + }); + } return; } catch (e) { console.warn("[MP4Client] append immediate failed, queueing", e); From 25d1d713bc408510ab7dc18837d382178933d5dd Mon Sep 17 00:00:00 2001 From: Hal Li Date: Sat, 22 Nov 2025 15:29:25 +0800 Subject: [PATCH 26/34] Rename package in README.md Updated package name from @gbox/live-view to @babelcloud/live-view in README. --- packages/live-view/README.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/live-view/README.md b/packages/live-view/README.md index 12323cbe..037b6e50 100644 --- a/packages/live-view/README.md +++ b/packages/live-view/README.md @@ -1,4 +1,4 @@ -# @gbox/live-view +# @babelcloud/live-view Live view component for Android device streaming using WebRTC. @@ -13,10 +13,19 @@ Live view component for Android device streaming using WebRTC. ## Installation +At first, you need to set up the registry and token of GitHub. Create a `.npmrc` file in the project root directory and add content as below. You can get your GitHub access token [here](https://github.com/settings/tokens). + +``` +@babelcloud:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${YOUR_GITHUB_PERSONAL_ACCESS_TOKEN} +``` + +Then + ```bash -npm install @gbox/live-view +npm install @babelcloud/live-view # or -pnpm add @gbox/live-view +pnpm add @babelcloud/live-view ``` ## Usage @@ -24,7 +33,7 @@ pnpm add @gbox/live-view ### As a React Component ```tsx -import { AndroidLiveView } from '@gbox/live-view'; +import { AndroidLiveView } from '@babelcloud/live-view'; function App() { return ( @@ -89,4 +98,4 @@ npm publish ## License -Apache-2.0 \ No newline at end of file +Apache-2.0 From c744ceb8e4cb115f26957556d7b1ede9ae2f8e36 Mon Sep 17 00:00:00 2001 From: Hal Li Date: Tue, 25 Nov 2025 16:41:10 +0800 Subject: [PATCH 27/34] Revise installation instructions in README.md Updated instructions for creating a GitHub token and clarified the installation steps. --- packages/live-view/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/live-view/README.md b/packages/live-view/README.md index 037b6e50..e1f0b9b3 100644 --- a/packages/live-view/README.md +++ b/packages/live-view/README.md @@ -13,14 +13,18 @@ Live view component for Android device streaming using WebRTC. ## Installation -At first, you need to set up the registry and token of GitHub. Create a `.npmrc` file in the project root directory and add content as below. You can get your GitHub access token [here](https://github.com/settings/tokens). +At first, go to [GitHub token settings](https://github.com/settings/tokens) to create a classic token. Make sure you checked `repo` and `read:packages` scopes. + +image + +Then, create a `.npmrc` file in the project root directory and add content as below. ``` @babelcloud:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${YOUR_GITHUB_PERSONAL_ACCESS_TOKEN} ``` -Then +Finally you can install it. ```bash npm install @babelcloud/live-view From 66ca05ea46cede444866433efd7f0f7cf480a3b9 Mon Sep 17 00:00:00 2001 From: Hal Date: Tue, 25 Nov 2025 17:47:37 +0800 Subject: [PATCH 28/34] change scope name --- packages/live-view/README.md | 21 ++++----------------- packages/live-view/package.json | 5 +---- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/packages/live-view/README.md b/packages/live-view/README.md index e1f0b9b3..cb33d444 100644 --- a/packages/live-view/README.md +++ b/packages/live-view/README.md @@ -1,4 +1,4 @@ -# @babelcloud/live-view +# @gbox.ai/live-view Live view component for Android device streaming using WebRTC. @@ -13,23 +13,10 @@ Live view component for Android device streaming using WebRTC. ## Installation -At first, go to [GitHub token settings](https://github.com/settings/tokens) to create a classic token. Make sure you checked `repo` and `read:packages` scopes. - -image - -Then, create a `.npmrc` file in the project root directory and add content as below. - -``` -@babelcloud:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${YOUR_GITHUB_PERSONAL_ACCESS_TOKEN} -``` - -Finally you can install it. - ```bash -npm install @babelcloud/live-view +npm install @gbox.ai/live-view # or -pnpm add @babelcloud/live-view +pnpm add @gbox.ai/live-view ``` ## Usage @@ -37,7 +24,7 @@ pnpm add @babelcloud/live-view ### As a React Component ```tsx -import { AndroidLiveView } from '@babelcloud/live-view'; +import { AndroidLiveView } from '@gbox.ai/live-view'; function App() { return ( diff --git a/packages/live-view/package.json b/packages/live-view/package.json index f1dfc943..cdea7703 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -1,5 +1,5 @@ { - "name": "@babelcloud/live-view", + "name": "@gbox.ai/live-view", "version": "0.7.0", "type": "module", "description": "Live view component for Android device streaming", @@ -64,9 +64,6 @@ "typescript": "^5.3.3", "vite": "^5.0.8" }, - "publishConfig": { - "registry": "https://npm.pkg.github.com" - }, "repository": { "type": "git", "url": "https://github.com/babelcloud/gbox.git", From 1e57ea6365f64596b514ac9450bc2ebe9d1f5efe Mon Sep 17 00:00:00 2001 From: Hal Date: Tue, 25 Nov 2025 17:58:12 +0800 Subject: [PATCH 29/34] make it public --- packages/live-view/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/live-view/package.json b/packages/live-view/package.json index cdea7703..48d60edd 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -64,6 +64,9 @@ "typescript": "^5.3.3", "vite": "^5.0.8" }, + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "https://github.com/babelcloud/gbox.git", From 1e64fa74e7b67f7623476b566d76ae29c9e5d93d Mon Sep 17 00:00:00 2001 From: Hal Date: Tue, 25 Nov 2025 19:24:05 +0800 Subject: [PATCH 30/34] change build file --- packages/live-view/vite.config.sdk.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/live-view/vite.config.sdk.ts b/packages/live-view/vite.config.sdk.ts index 7c067330..58c321ed 100644 --- a/packages/live-view/vite.config.sdk.ts +++ b/packages/live-view/vite.config.sdk.ts @@ -5,7 +5,9 @@ import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [ - react(), + react({ + jsxRuntime: 'automatic', + }), // dts({ // insertTypesEntry: true, // include: ['sdk/**/*'], @@ -22,11 +24,16 @@ export default defineConfig({ }, rollupOptions: { // 不要把 peerDependencies 打包进去 - external: ['react', 'react-dom'], + external: (id) => { + // 排除所有 react 相关的包 + return /^react($|\/|$)/.test(id) || /^react-dom($|\/|$)/.test(id); + }, output: { globals: { react: 'React', - 'react-dom': 'ReactDOM' + 'react-dom': 'ReactDOM', + 'react/jsx-runtime': 'React', + 'react-dom/client': 'ReactDOM' } } }, From 85eb951b2e87b5aed1d428d7dbf21ab72a1e8207 Mon Sep 17 00:00:00 2001 From: Hal Date: Tue, 25 Nov 2025 19:25:05 +0800 Subject: [PATCH 31/34] update version --- packages/live-view/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/live-view/package.json b/packages/live-view/package.json index 48d60edd..77640ec5 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -1,6 +1,6 @@ { "name": "@gbox.ai/live-view", - "version": "0.7.0", + "version": "0.7.1", "type": "module", "description": "Live view component for Android device streaming", "main": "dist/index.umd.js", From 35d4abea203310729b7c06d01cda411b7802c2a5 Mon Sep 17 00:00:00 2001 From: Hal Date: Tue, 25 Nov 2025 19:51:48 +0800 Subject: [PATCH 32/34] fix errors --- packages/live-view/README.md | 30 +- packages/live-view/package.json | 14 +- packages/live-view/pnpm-lock.yaml | 526 ++++++++++++++++++ .../sdk/AndroidLiveviewComponent.tsx | 6 +- packages/live-view/sdk/css-modules.d.ts | 5 + packages/live-view/sdk/index.ts | 5 +- packages/live-view/sdk/use-sdk-entry.tsx | 2 +- packages/live-view/tsconfig.dts.json | 18 + packages/live-view/vite.config.sdk.ts | 21 +- 9 files changed, 608 insertions(+), 19 deletions(-) create mode 100644 packages/live-view/sdk/css-modules.d.ts create mode 100644 packages/live-view/tsconfig.dts.json diff --git a/packages/live-view/README.md b/packages/live-view/README.md index cb33d444..823423a5 100644 --- a/packages/live-view/README.md +++ b/packages/live-view/README.md @@ -24,16 +24,20 @@ pnpm add @gbox.ai/live-view ### As a React Component ```tsx -import { AndroidLiveView } from '@gbox.ai/live-view'; +// Import styles +import '@gbox.ai/live-view/style.css'; + +// import component +import AndroidLiveView from '@gbox.ai/live-view'; function App() { return ( console.log('Connected to', device)} onDisconnect={() => console.log('Disconnected')} onError={(error) => console.error('Error:', error)} @@ -42,6 +46,20 @@ function App() { } ``` +#### Next.js Usage Example + +In Next.js projects, you can import styles in `app/layout.tsx` or `pages/_app.tsx`: + +**App Router (app/layout.tsx):** +```tsx +import '@gbox.ai/live-view/style.css'; +``` + +**Pages Router (pages/_app.tsx):** +```tsx +import '@gbox.ai/live-view/style.css'; +``` + ### Props - `apiUrl`: API endpoint URL (default: `/api`) diff --git a/packages/live-view/package.json b/packages/live-view/package.json index 77640ec5..0718698a 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -6,13 +6,22 @@ "main": "dist/index.umd.js", "module": "dist/index.es.js", "types": "dist/index.d.ts", + "sideEffects": [ + "./dist/style.css", + "./dist/index.es.js", + "./dist/index.umd.js" + ], "exports": { ".": { "import": "./dist/index.es.js", "require": "./dist/index.umd.js", "types": "./dist/index.d.ts" }, - "./style.css": "./dist/style.css" + "./style.css": { + "import": "./dist/style.css", + "require": "./dist/style.css", + "default": "./dist/style.css" + } }, "files": [ "dist", @@ -62,7 +71,8 @@ "rollup-plugin-postcss": "^4.0.2", "ts-jest": "^29.1.1", "typescript": "^5.3.3", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vite-plugin-dts": "^4.5.4" }, "publishConfig": { "access": "public" diff --git a/packages/live-view/pnpm-lock.yaml b/packages/live-view/pnpm-lock.yaml index be995013..84a34aa4 100644 --- a/packages/live-view/pnpm-lock.yaml +++ b/packages/live-view/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: vite: specifier: ^5.0.8 version: 5.4.20(@types/node@24.5.2) + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4(@types/node@24.5.2)(rollup@4.50.1)(typescript@5.9.2)(vite@5.4.20(@types/node@24.5.2)) packages: @@ -138,6 +141,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -151,6 +158,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-async-generators@7.8.4': resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: @@ -270,6 +282,10 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -465,6 +481,14 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -555,6 +579,19 @@ packages: '@jridgewell/trace-mapping@0.3.30': resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@microsoft/api-extractor-model@7.32.1': + resolution: {integrity: sha512-u4yJytMYiUAnhcNQcZDTh/tVtlrzKlyKrQnLOV+4Qr/5gV+cpufWzCYAB1Q23URFqD6z2RoL2UYncM9xJVGNKA==} + + '@microsoft/api-extractor@7.55.1': + resolution: {integrity: sha512-l8Z+8qrLkZFM3HM95Dbpqs6G39fpCa7O5p8A7AkA6hSevxkgwsOlLrEuPv0ADOyj5dI1Af5WVDiwpKG/ya5G3w==} + hasBin: true + + '@microsoft/tsdoc-config@0.18.0': + resolution: {integrity: sha512-8N/vClYyfOH+l4fLkkr9+myAoR6M7akc8ntBJ4DJdWH2b09uVfr71+LTMpNyG19fNqWDg8KEDZhx5wxuqHyGjw==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -715,6 +752,36 @@ packages: cpu: [x64] os: [win32] + '@rushstack/node-core-library@5.19.0': + resolution: {integrity: sha512-BxAopbeWBvNJ6VGiUL+5lbJXywTdsnMeOS8j57Cn/xY10r6sV/gbsTlfYKjzVCUBZATX2eRzJHSMCchsMTGN6A==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/problem-matcher@0.1.1': + resolution: {integrity: sha512-Fm5XtS7+G8HLcJHCWpES5VmeMyjAKaWeyZU5qPzZC+22mPlJzAsOxymHiWIfuirtPckX3aptWws+K2d0BzniJA==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/rig-package@0.6.0': + resolution: {integrity: sha512-ZQmfzsLE2+Y91GF15c65L/slMRVhF6Hycq04D4TwtdGaUAbIXXg9c5pKA5KFU7M4QMaihoobp9JJYpYcaY3zOw==} + + '@rushstack/terminal@0.19.4': + resolution: {integrity: sha512-f4XQk02CrKfrMgyOfhYd3qWI944dLC21S4I/LUhrlAP23GTMDNG6EK5effQtFkISwUKCgD9vMBrJZaPSUquxWQ==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@5.1.4': + resolution: {integrity: sha512-H0I6VdJ6sOUbktDFpP2VW5N29w8v4hRoNZOQz02vtEi6ZTYL1Ju8u+TcFiFawUDrUsx/5MQTUhd79uwZZVwVlA==} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -755,6 +822,9 @@ packages: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -888,6 +958,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@volar/language-core@2.4.26': + resolution: {integrity: sha512-hH0SMitMxnB43OZpyF1IFPS9bgb2I3bpCh76m2WEK7BE0A0EzpYsRp0CCH2xNKshr7kacU5TQBLYn4zj7CG60A==} + + '@volar/source-map@2.4.26': + resolution: {integrity: sha512-JJw0Tt/kSFsIRmgTQF4JSt81AUSI1aEye5Zl65EeZ8H35JHnTvFGmpDOBn5iOxd48fyGE+ZvZBp5FcgAy/1Qhw==} + + '@volar/typescript@2.4.26': + resolution: {integrity: sha512-N87ecLD48Sp6zV9zID/5yuS1+5foj0DfuYGdQ6KHj/IbKvyKv1zNX6VCmnKYwtmHadEO6mFc2EKISiu3RDPAvA==} + + '@vue/compiler-core@3.5.25': + resolution: {integrity: sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==} + + '@vue/compiler-dom@3.5.25': + resolution: {integrity: sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/language-core@2.2.0': + resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/shared@3.5.25': + resolution: {integrity: sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -913,9 +1012,34 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + + ajv@8.13.0: + resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + + alien-signals@0.4.14: + resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1121,12 +1245,21 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} concat-with-sourcemaps@1.1.0: resolution: {integrity: sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1215,6 +1348,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -1266,6 +1402,10 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@8.0.2: + resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + engines: {node: '>=0.3.1'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1311,6 +1451,10 @@ packages: entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -1452,6 +1596,9 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1502,6 +1649,10 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1618,6 +1769,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + html-encoding-sniffer@3.0.0: resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} engines: {node: '>=12'} @@ -1670,6 +1825,10 @@ packages: resolution: {integrity: sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==} engines: {node: '>=8'} + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -1990,6 +2149,9 @@ packages: node-notifier: optional: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2024,6 +2186,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2032,6 +2197,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -2043,6 +2211,9 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -2062,6 +2233,10 @@ packages: resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} engines: {node: '>= 12.13.0'} + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -2082,6 +2257,9 @@ packages: lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2089,6 +2267,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2140,6 +2322,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2154,9 +2340,15 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2278,6 +2470,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2293,6 +2488,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2316,6 +2514,12 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2570,6 +2774,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -2614,6 +2821,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -2704,6 +2915,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -2781,6 +2997,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-hash@1.1.3: resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} @@ -2947,11 +3167,19 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typescript@5.8.2: + resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -2968,6 +3196,10 @@ packages: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -2987,6 +3219,15 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + vite-plugin-dts@4.5.4: + resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} + peerDependencies: + typescript: '*' + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite@5.4.20: resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3018,6 +3259,9 @@ packages: terser: optional: true + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-xmlserializer@4.0.0: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'} @@ -3106,6 +3350,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -3194,6 +3441,8 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.28.4': @@ -3205,6 +3454,10 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 @@ -3325,6 +3578,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} '@esbuild/aix-ppc64@0.21.5': @@ -3451,6 +3709,12 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -3642,6 +3906,42 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@microsoft/api-extractor-model@7.32.1(@types/node@24.5.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.0 + '@rushstack/node-core-library': 5.19.0(@types/node@24.5.2) + transitivePeerDependencies: + - '@types/node' + + '@microsoft/api-extractor@7.55.1(@types/node@24.5.2)': + dependencies: + '@microsoft/api-extractor-model': 7.32.1(@types/node@24.5.2) + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.0 + '@rushstack/node-core-library': 5.19.0(@types/node@24.5.2) + '@rushstack/rig-package': 0.6.0 + '@rushstack/terminal': 0.19.4(@types/node@24.5.2) + '@rushstack/ts-command-line': 5.1.4(@types/node@24.5.2) + diff: 8.0.2 + lodash: 4.17.21 + minimatch: 10.0.3 + resolve: 1.22.10 + semver: 7.5.4 + source-map: 0.6.1 + typescript: 5.8.2 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/tsdoc-config@0.18.0': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.12.0 + jju: 1.4.0 + resolve: 1.22.10 + + '@microsoft/tsdoc@0.16.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3757,6 +4057,45 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.50.1': optional: true + '@rushstack/node-core-library@5.19.0(@types/node@24.5.2)': + dependencies: + ajv: 8.13.0 + ajv-draft-04: 1.0.0(ajv@8.13.0) + ajv-formats: 3.0.1(ajv@8.13.0) + fs-extra: 11.3.2 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.10 + semver: 7.5.4 + optionalDependencies: + '@types/node': 24.5.2 + + '@rushstack/problem-matcher@0.1.1(@types/node@24.5.2)': + optionalDependencies: + '@types/node': 24.5.2 + + '@rushstack/rig-package@0.6.0': + dependencies: + resolve: 1.22.10 + strip-json-comments: 3.1.1 + + '@rushstack/terminal@0.19.4(@types/node@24.5.2)': + dependencies: + '@rushstack/node-core-library': 5.19.0(@types/node@24.5.2) + '@rushstack/problem-matcher': 0.1.1(@types/node@24.5.2) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 24.5.2 + + '@rushstack/ts-command-line@5.1.4(@types/node@24.5.2)': + dependencies: + '@rushstack/terminal': 0.19.4(@types/node@24.5.2) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -3801,6 +4140,8 @@ snapshots: '@trysound/sax@0.2.0': {} + '@types/argparse@1.0.38': {} + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -3985,6 +4326,51 @@ snapshots: transitivePeerDependencies: - supports-color + '@volar/language-core@2.4.26': + dependencies: + '@volar/source-map': 2.4.26 + + '@volar/source-map@2.4.26': {} + + '@volar/typescript@2.4.26': + dependencies: + '@volar/language-core': 2.4.26 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.25': + dependencies: + '@babel/parser': 7.28.5 + '@vue/shared': 3.5.25 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.25': + dependencies: + '@vue/compiler-core': 3.5.25 + '@vue/shared': 3.5.25 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/language-core@2.2.0(typescript@5.9.2)': + dependencies: + '@volar/language-core': 2.4.26 + '@vue/compiler-dom': 3.5.25 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.25 + alien-signals: 0.4.14 + minimatch: 9.0.5 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.2 + + '@vue/shared@3.5.25': {} + abab@2.0.6: {} acorn-globals@7.0.1: @@ -4008,6 +4394,14 @@ snapshots: transitivePeerDependencies: - supports-color + ajv-draft-04@1.0.0(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + + ajv-formats@3.0.1(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -4015,6 +4409,22 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + ajv@8.13.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + alien-signals@0.4.14: {} + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -4267,12 +4677,18 @@ snapshots: commondir@1.0.1: {} + compare-versions@6.1.1: {} + concat-map@0.0.1: {} concat-with-sourcemaps@1.1.0: dependencies: source-map: 0.6.1 + confbox@0.1.8: {} + + confbox@0.2.2: {} + convert-source-map@2.0.0: {} create-jest@29.7.0(@types/node@24.5.2): @@ -4401,6 +4817,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + de-indent@1.0.2: {} + debug@4.4.1: dependencies: ms: 2.1.3 @@ -4433,6 +4851,8 @@ snapshots: diff-sequences@29.6.3: {} + diff@8.0.2: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -4477,6 +4897,8 @@ snapshots: entities@2.2.0: {} + entities@4.5.0: {} + entities@6.0.1: {} error-ex@1.3.4: @@ -4747,6 +5169,8 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + exsolve@1.0.8: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -4806,6 +5230,12 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -4929,6 +5359,8 @@ snapshots: dependencies: function-bind: 1.1.2 + he@1.2.0: {} + html-encoding-sniffer@3.0.0: dependencies: whatwg-encoding: 2.0.0 @@ -4979,6 +5411,8 @@ snapshots: dependencies: resolve-from: 5.0.0 + import-lazy@4.0.0: {} + import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -5501,6 +5935,8 @@ snapshots: - supports-color - ts-node + jju@1.4.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -5553,10 +5989,18 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -5570,6 +6014,8 @@ snapshots: kleur@3.0.3: {} + kolorist@1.8.0: {} + leven@3.1.0: {} levn@0.4.1: @@ -5583,6 +6029,12 @@ snapshots: loader-utils@3.3.1: {} + local-pkg@1.1.2: + dependencies: + mlly: 1.8.0 + pkg-types: 2.3.0 + quansync: 0.2.11 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -5599,6 +6051,8 @@ snapshots: lodash.uniq@4.5.0: {} + lodash@4.17.21: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5607,6 +6061,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lz-string@1.5.0: {} magic-string@0.30.19: @@ -5646,6 +6104,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.0.3: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -5660,8 +6122,17 @@ snapshots: minimist@1.2.8: {} + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 + ms@2.1.3: {} + muggle-string@0.4.1: {} + nanoid@3.3.11: {} natural-compare@1.4.0: {} @@ -5789,6 +6260,8 @@ snapshots: dependencies: entities: 6.0.1 + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -5797,6 +6270,8 @@ snapshots: path-parse@1.0.7: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5811,6 +6286,18 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + possible-typed-array-names@1.1.0: {} postcss-calc@8.2.4(postcss@8.5.6): @@ -6054,6 +6541,8 @@ snapshots: pure-rand@6.1.0: {} + quansync@0.2.11: {} + querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -6103,6 +6592,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + requires-port@1.0.0: {} resolve-cwd@3.0.0: @@ -6228,6 +6719,10 @@ snapshots: semver@6.3.1: {} + semver@7.5.4: + dependencies: + lru-cache: 6.0.0 + semver@7.7.2: {} set-function-length@1.2.2: @@ -6314,6 +6809,8 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-argv@0.3.2: {} + string-hash@1.1.3: {} string-length@4.0.2: @@ -6508,8 +7005,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typescript@5.8.2: {} + typescript@5.9.2: {} + ufo@1.6.1: {} + uglify-js@3.19.3: optional: true @@ -6524,6 +7025,8 @@ snapshots: universalify@0.2.0: {} + universalify@2.0.1: {} + update-browserslist-db@1.1.3(browserslist@4.25.4): dependencies: browserslist: 4.25.4 @@ -6547,6 +7050,25 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + vite-plugin-dts@4.5.4(@types/node@24.5.2)(rollup@4.50.1)(typescript@5.9.2)(vite@5.4.20(@types/node@24.5.2)): + dependencies: + '@microsoft/api-extractor': 7.55.1(@types/node@24.5.2) + '@rollup/pluginutils': 5.3.0(rollup@4.50.1) + '@volar/typescript': 2.4.26 + '@vue/language-core': 2.2.0(typescript@5.9.2) + compare-versions: 6.1.1 + debug: 4.4.1 + kolorist: 1.8.0 + local-pkg: 1.1.2 + magic-string: 0.30.19 + typescript: 5.9.2 + optionalDependencies: + vite: 5.4.20(@types/node@24.5.2) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite@5.4.20(@types/node@24.5.2): dependencies: esbuild: 0.21.5 @@ -6556,6 +7078,8 @@ snapshots: '@types/node': 24.5.2 fsevents: 2.3.3 + vscode-uri@3.1.0: {} + w3c-xmlserializer@4.0.0: dependencies: xml-name-validator: 4.0.0 @@ -6649,6 +7173,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@1.10.2: {} yargs-parser@21.1.1: {} diff --git a/packages/live-view/sdk/AndroidLiveviewComponent.tsx b/packages/live-view/sdk/AndroidLiveviewComponent.tsx index ee7a5211..0b461be8 100644 --- a/packages/live-view/sdk/AndroidLiveviewComponent.tsx +++ b/packages/live-view/sdk/AndroidLiveviewComponent.tsx @@ -22,7 +22,7 @@ export interface AndroidLiveviewComponentProps { onStatsUpdate?: (stats: Stats) => void; onConnect?: (device: Device) => void; onDisconnect?: () => void; - connectaParams: { + connectParams: { deviceSerial: string; apiUrl: string; wsUrl: string; @@ -36,7 +36,7 @@ export function AndroidLiveviewComponent(props: AndroidLiveviewComponentProps) { onStatsUpdate: _onStatsUpdate, onConnect: _onConnect, onDisconnect, - connectaParams, + connectParams, } = props; const clientRef = useRef(null); @@ -104,7 +104,7 @@ export function AndroidLiveviewComponent(props: AndroidLiveviewComponentProps) { controlHandler.handleControlAction(action); }, [controlHandler]); - const { deviceSerial, apiUrl, wsUrl } = connectaParams; + const { deviceSerial, apiUrl, wsUrl } = connectParams; useEffect(() => { console.log("===="); diff --git a/packages/live-view/sdk/css-modules.d.ts b/packages/live-view/sdk/css-modules.d.ts new file mode 100644 index 00000000..dbaa638a --- /dev/null +++ b/packages/live-view/sdk/css-modules.d.ts @@ -0,0 +1,5 @@ +declare module "*.module.css" { + const classes: { [key: string]: string }; + export default classes; +} + diff --git a/packages/live-view/sdk/index.ts b/packages/live-view/sdk/index.ts index 5f12edaf..a7d2b9e5 100644 --- a/packages/live-view/sdk/index.ts +++ b/packages/live-view/sdk/index.ts @@ -1,3 +1,6 @@ import { AndroidLiveviewComponent } from "./AndroidLiveviewComponent"; export default AndroidLiveviewComponent; -export type { AndroidLiveviewComponentProps } from "./AndroidLiveviewComponent"; \ No newline at end of file +export type { AndroidLiveviewComponentProps } from "./AndroidLiveviewComponent"; + +// 重新导出类型,确保它们被包含在类型定义文件中 +export type { ConnectionState, Device, Stats } from "../src/types"; \ No newline at end of file diff --git a/packages/live-view/sdk/use-sdk-entry.tsx b/packages/live-view/sdk/use-sdk-entry.tsx index a6897edc..d476cf65 100644 --- a/packages/live-view/sdk/use-sdk-entry.tsx +++ b/packages/live-view/sdk/use-sdk-entry.tsx @@ -8,7 +8,7 @@ if (!rootElement) { ReactDOM.createRoot(rootElement).render( Date: Tue, 25 Nov 2025 20:02:14 +0800 Subject: [PATCH 33/34] update README and package.json --- packages/live-view/README.md | 4 ++-- packages/live-view/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/live-view/README.md b/packages/live-view/README.md index 823423a5..6e760de9 100644 --- a/packages/live-view/README.md +++ b/packages/live-view/README.md @@ -34,9 +34,9 @@ function App() { return ( console.log('Connected to', device)} onDisconnect={() => console.log('Disconnected')} diff --git a/packages/live-view/package.json b/packages/live-view/package.json index 0718698a..b689a2b5 100644 --- a/packages/live-view/package.json +++ b/packages/live-view/package.json @@ -1,6 +1,6 @@ { "name": "@gbox.ai/live-view", - "version": "0.7.1", + "version": "0.7.2", "type": "module", "description": "Live view component for Android device streaming", "main": "dist/index.umd.js", From ad0b47e5491970aa99af585d8e59b5c688956c2f Mon Sep 17 00:00:00 2001 From: Hal Date: Thu, 27 Nov 2025 15:45:44 +0800 Subject: [PATCH 34/34] resolve conflicts again --- packages/cli/cmd/render.go | 63 ---- packages/cli/go.mod | 3 +- .../internal/device_connect/api/handlers.go | 172 ---------- .../cli/internal/device_connect/api/server.go | 177 ---------- .../internal/device_connect/api/websocket.go | 323 ------------------ .../internal/device_connect/device/manager.go | 148 -------- 6 files changed, 1 insertion(+), 885 deletions(-) delete mode 100644 packages/cli/cmd/render.go delete mode 100644 packages/cli/internal/device_connect/api/handlers.go delete mode 100644 packages/cli/internal/device_connect/api/server.go delete mode 100644 packages/cli/internal/device_connect/api/websocket.go delete mode 100644 packages/cli/internal/device_connect/device/manager.go diff --git a/packages/cli/cmd/render.go b/packages/cli/cmd/render.go deleted file mode 100644 index 1b31753b..00000000 --- a/packages/cli/cmd/render.go +++ /dev/null @@ -1,63 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" -) - -// TableColumn represents a column in a table -type TableColumn struct { - Header string - Key string // key to extract from data map - Width int // calculated width -} - -// RenderTable renders a table with dynamic column width calculation -func RenderTable(columns []TableColumn, data []map[string]interface{}) { - if len(data) == 0 { - fmt.Println("No data to display") - return - } - - // Calculate column widths based on header and data - for i := range columns { - columns[i].Width = len(columns[i].Header) - for _, row := range data { - if value, exists := row[columns[i].Key]; exists { - valueStr := fmt.Sprintf("%v", value) - if len(valueStr) > columns[i].Width { - columns[i].Width = len(valueStr) - } - } - } - } - - // Print header - var headerParts []string - for _, col := range columns { - headerParts = append(headerParts, fmt.Sprintf("%-*s", col.Width, col.Header)) - } - header := strings.Join(headerParts, " ") - fmt.Println(header) - - // Print separator - var separatorParts []string - for _, col := range columns { - separatorParts = append(separatorParts, strings.Repeat("-", col.Width)) - } - separator := strings.Join(separatorParts, " ") - fmt.Println(separator) - - // Print data rows - for _, row := range data { - var rowParts []string - for _, col := range columns { - value := "" - if v, exists := row[col.Key]; exists { - value = fmt.Sprintf("%v", v) - } - rowParts = append(rowParts, fmt.Sprintf("%-*s", col.Width, value)) - } - fmt.Println(strings.Join(rowParts, " ")) - } -} diff --git a/packages/cli/go.mod b/packages/cli/go.mod index 7c5b9f65..4ff3d285 100644 --- a/packages/cli/go.mod +++ b/packages/cli/go.mod @@ -45,7 +45,6 @@ require ( github.com/asticode/go-astits v1.13.0 // indirect github.com/briandowns/spinner v1.23.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pion/datachannel v1.5.10 // indirect @@ -81,4 +80,4 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/sirupsen/logrus v1.9.3 github.com/spf13/pflag v1.0.6 // indirect -) +) \ No newline at end of file diff --git a/packages/cli/internal/device_connect/api/handlers.go b/packages/cli/internal/device_connect/api/handlers.go deleted file mode 100644 index 5a130294..00000000 --- a/packages/cli/internal/device_connect/api/handlers.go +++ /dev/null @@ -1,172 +0,0 @@ -package api - -import ( - "encoding/json" - "log" - "net/http" - "strings" -) - -// handleDevices handles GET /api/devices -func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - devices, err := s.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 - } - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "devices": devices, - "onDemandEnabled": true, - }) -} - -// handleDeviceAction handles /api/devices/{id}/{action} -func (s *Server) handleDeviceAction(w http.ResponseWriter, r *http.Request) { - // Parse URL path: /api/devices/{id}/{action} - path := strings.TrimPrefix(r.URL.Path, "/api/devices/") - parts := strings.Split(path, "/") - - if len(parts) != 2 { - http.Error(w, "Invalid path", http.StatusBadRequest) - return - } - - deviceID := parts[0] - action := parts[1] - - switch action { - case "connect": - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - s.handleDeviceConnect(w, r, deviceID) - case "disconnect": - if r.Method != http.MethodDelete { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - s.handleDeviceDisconnect(w, r, deviceID) - default: - http.Error(w, "Unknown action", http.StatusNotFound) - } -} - -// handleDeviceConnect handles POST /api/devices/{id}/connect -func (s *Server) handleDeviceConnect(w http.ResponseWriter, r *http.Request, deviceID string) { - // Create WebRTC bridge for the device - bridge, err := s.bridgeManager.CreateBridge(deviceID) - if err != nil { - log.Printf("Failed to create bridge for device %s: %v", deviceID, err) - respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - return - } - - // Mark device as registered - s.deviceManager.RegisterDevice(deviceID) - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "deviceId": deviceID, - "proxyId": bridge.DeviceSerial, - "message": "Device connected successfully", - }) -} - -// handleDeviceDisconnect handles DELETE /api/devices/{id}/disconnect -func (s *Server) handleDeviceDisconnect(w http.ResponseWriter, r *http.Request, deviceID string) { - // Remove WebRTC bridge - s.bridgeManager.RemoveBridge(deviceID) - - // Mark device as unregistered - s.deviceManager.UnregisterDevice(deviceID) - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "deviceId": deviceID, - "message": "Device disconnected successfully", - }) -} - -// handleRegisterDevice handles POST /api/register-device -func (s *Server) handleRegisterDevice(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - DeviceID string `json:"deviceId"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondJSON(w, http.StatusBadRequest, map[string]interface{}{ - "success": false, - "error": "Invalid request body", - }) - return - } - - bridge, err := s.bridgeManager.CreateBridge(req.DeviceID) - if err != nil { - log.Printf("Failed to create bridge for device %s: %v", req.DeviceID, err) - respondJSON(w, http.StatusInternalServerError, map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - return - } - - s.deviceManager.RegisterDevice(req.DeviceID) - - log.Printf("Successfully registered device %s", req.DeviceID) - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "device_id": bridge.DeviceSerial, - "message": "Device registered successfully", - }) -} - -// handleUnregisterDevice handles POST /api/unregister-device -func (s *Server) handleUnregisterDevice(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - DeviceID string `json:"deviceId"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondJSON(w, http.StatusBadRequest, map[string]interface{}{ - "success": false, - "error": "Invalid request body", - }) - return - } - - s.bridgeManager.RemoveBridge(req.DeviceID) - s.deviceManager.UnregisterDevice(req.DeviceID) - - log.Printf("Successfully unregistered device %s", req.DeviceID) - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "message": "Device unregistered successfully", - }) -} \ No newline at end of file diff --git a/packages/cli/internal/device_connect/api/server.go b/packages/cli/internal/device_connect/api/server.go deleted file mode 100644 index 4e3d191b..00000000 --- a/packages/cli/internal/device_connect/api/server.go +++ /dev/null @@ -1,177 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "path/filepath" - "time" - - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/device" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/webrtc" -) - -// Server handles HTTP API and WebSocket connections -type Server struct { - port int - server *http.Server - deviceManager *device.Manager - bridgeManager *webrtc.Manager - isRunning bool -} - -// NewServer creates a new API server -func NewServer(port int) *Server { - deviceManager := device.NewManager() - - // Get ADB path for bridge manager - adbPath := "adb" - bridgeManager := webrtc.NewManager(adbPath) - - return &Server{ - port: port, - deviceManager: deviceManager, - bridgeManager: bridgeManager, - } -} - -// Start starts the HTTP server -func (s *Server) Start() error { - if s.isRunning { - return fmt.Errorf("server already running") - } - - // Setup routes - mux := http.NewServeMux() - - // API routes - mux.HandleFunc("/api/devices", s.handleDevices) - mux.HandleFunc("/api/devices/", s.handleDeviceAction) - mux.HandleFunc("/api/register-device", s.handleRegisterDevice) - mux.HandleFunc("/api/unregister-device", s.handleUnregisterDevice) - - // WebSocket route - mux.HandleFunc("/ws", s.handleWebSocket) - - // Static files - staticPath := s.findLiveViewStaticPath() - if staticPath != "" { - log.Printf("Serving static files from: %s", staticPath) - fs := http.FileServer(http.Dir(staticPath)) - mux.Handle("/", fs) - } else { - log.Println("Warning: Live-view static files not found") - // Return 404 for static files if not found - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.NotFound(w, r) - }) - } - - // Create HTTP server - s.server = &http.Server{ - Addr: fmt.Sprintf(":%d", s.port), - Handler: mux, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - } - - // Start server - log.Printf("Starting API server on port %d", s.port) - s.isRunning = true - - go func() { - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("Server error: %v", err) - s.isRunning = false - } - }() - - // Wait for server to start - time.Sleep(100 * time.Millisecond) - - // Test if server is accessible - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/devices", s.port)) - if err != nil { - s.isRunning = false - return fmt.Errorf("server failed to start: %w", err) - } - resp.Body.Close() - - log.Printf("API server started successfully on http://localhost:%d", s.port) - return nil -} - -// Stop stops the HTTP server -func (s *Server) Stop() error { - if !s.isRunning { - return nil - } - - log.Println("Stopping API server...") - - // Close bridge manager - if s.bridgeManager != nil { - s.bridgeManager.Close() - } - - // Shutdown HTTP server - if s.server != nil { - if err := s.server.Close(); err != nil { - log.Printf("Error closing server: %v", err) - } - } - - s.isRunning = false - log.Println("API server stopped") - - return nil -} - -// IsRunning returns whether the server is running -func (s *Server) IsRunning() bool { - return s.isRunning -} - -// findLiveViewStaticPath finds the live-view static files -func (s *Server) findLiveViewStaticPath() string { - searchPaths := []string{ - "../../live-view/dist/static", - "../live-view/dist/static", - "packages/live-view/dist/static", - "live-view/dist/static", - "dist/static", - "static", - } - - // Also check relative to executable - if exe, err := os.Executable(); err == nil { - exeDir := filepath.Dir(exe) - searchPaths = append([]string{ - filepath.Join(exeDir, "static"), - filepath.Join(exeDir, "..", "live-view", "dist", "static"), - filepath.Join(exeDir, "..", "..", "live-view", "dist", "static"), - }, searchPaths...) - } - - for _, path := range searchPaths { - if info, err := os.Stat(path); err == nil && info.IsDir() { - absPath, _ := filepath.Abs(path) - return absPath - } - } - - return "" -} - -// respondJSON sends a JSON response -func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) - - // Use json encoder to write response - if err := json.NewEncoder(w).Encode(data); err != nil { - log.Printf("Failed to encode JSON response: %v", err) - } -} diff --git a/packages/cli/internal/device_connect/api/websocket.go b/packages/cli/internal/device_connect/api/websocket.go deleted file mode 100644 index e49a491b..00000000 --- a/packages/cli/internal/device_connect/api/websocket.go +++ /dev/null @@ -1,323 +0,0 @@ -package api - -import ( - "fmt" - "log" - "net/http" - - "github.com/gorilla/websocket" - "github.com/pion/webrtc/v4" -) - -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true // Allow all origins for now - }, -} - -// handleWebSocket handles WebSocket connections for WebRTC signaling -func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Printf("Failed to upgrade WebSocket: %v", err) - return - } - defer conn.Close() - - log.Println("WebSocket connection established") - - for { - var msg map[string]interface{} - if err := conn.ReadJSON(&msg); err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - log.Printf("WebSocket read error: %v", err) - } - break - } - - msgType, ok := msg["type"].(string) - if !ok { - continue - } - - switch msgType { - case "connect": - s.handleWebSocketConnect(conn, msg) - case "offer": - s.handleWebSocketOffer(conn, msg) - case "ice-candidate": - s.handleWebSocketICECandidate(conn, msg) - case "disconnect": - s.handleWebSocketDisconnect(conn, msg) - case "touch": - s.handleWebSocketTouch(conn, msg) - case "key": - s.handleWebSocketKey(conn, msg) - case "scroll": - s.handleWebSocketScroll(conn, msg) - } - } -} - -// handleWebSocketConnect handles WebSocket connect message -func (s *Server) handleWebSocketConnect(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": "Device serial required", - }) - return - } - - bridge, exists := s.bridgeManager.GetBridge(deviceSerial) - if !exists { - var err error - bridge, err = s.bridgeManager.CreateBridge(deviceSerial) - if err != nil { - log.Printf("Failed to create bridge: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": err.Error(), - }) - return - } - } - - bridge.WSConnection = conn - - conn.WriteJSON(map[string]interface{}{ - "type": "connected", - "deviceSerial": deviceSerial, - }) -} - -// handleWebSocketOffer handles WebRTC offer -func (s *Server) handleWebSocketOffer(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - return - } - - offerData, ok := msg["offer"].(map[string]interface{}) - if !ok { - return - } - - sdp, ok := offerData["sdp"].(string) - if !ok { - return - } - - // Get or create bridge for the device - bridge, exists := s.bridgeManager.GetBridge(deviceSerial) - if !exists { - log.Printf("Bridge not found for device %s, creating new bridge", deviceSerial) - var err error - bridge, err = s.bridgeManager.CreateBridge(deviceSerial) - if err != nil { - log.Printf("Failed to create bridge: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": fmt.Sprintf("Failed to connect to device: %v", err), - }) - return - } - } - - // Check signaling state - only recreate if truly necessary - signalingState := bridge.WebRTCConn.SignalingState() - connState := bridge.WebRTCConn.ConnectionState() - - log.Printf("Bridge state for device %s: signaling=%s, connection=%s", deviceSerial, signalingState, connState) - - // Only recreate bridge if connection is truly closed or failed - if connState == webrtc.PeerConnectionStateClosed || connState == webrtc.PeerConnectionStateFailed { - log.Printf("WebRTC connection is %s for device %s, recreating bridge", connState, deviceSerial) - s.bridgeManager.RemoveBridge(deviceSerial) - - // Create new bridge - var err error - bridge, err = s.bridgeManager.CreateBridge(deviceSerial) - if err != nil { - log.Printf("Failed to recreate bridge: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": fmt.Sprintf("Failed to reconnect to device: %v", err), - }) - return - } - } else if signalingState == webrtc.SignalingStateClosed { - // Only recreate if signaling is closed but connection is still active - log.Printf("Signaling state is closed for device %s, recreating bridge", deviceSerial) - s.bridgeManager.RemoveBridge(deviceSerial) - - var err error - bridge, err = s.bridgeManager.CreateBridge(deviceSerial) - if err != nil { - log.Printf("Failed to recreate bridge: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": fmt.Sprintf("Failed to reset connection: %v", err), - }) - return - } - } - - offer := webrtc.SessionDescription{ - Type: webrtc.SDPTypeOffer, - SDP: sdp, - } - - if err := bridge.WebRTCConn.SetRemoteDescription(offer); err != nil { - log.Printf("Failed to set remote description: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": err.Error(), - }) - return - } - - answer, err := bridge.WebRTCConn.CreateAnswer(nil) - if err != nil { - log.Printf("Failed to create answer: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": err.Error(), - }) - return - } - - if err := bridge.WebRTCConn.SetLocalDescription(answer); err != nil { - log.Printf("Failed to set local description: %v", err) - conn.WriteJSON(map[string]interface{}{ - "type": "error", - "error": err.Error(), - }) - return - } - - conn.WriteJSON(map[string]interface{}{ - "type": "answer", - "answer": map[string]interface{}{ - "type": "answer", - "sdp": answer.SDP, - }, - }) - - // Set up ICE candidate handler - bridge.WebRTCConn.OnICECandidate(func(candidate *webrtc.ICECandidate) { - if candidate == nil { - return - } - - candidateJSON := candidate.ToJSON() - conn.WriteJSON(map[string]interface{}{ - "type": "ice-candidate", - "candidate": map[string]interface{}{ - "candidate": candidateJSON.Candidate, - "sdpMLineIndex": candidateJSON.SDPMLineIndex, - "sdpMid": candidateJSON.SDPMid, - }, - }) - }) - - // Device info is not needed by frontend, video dimensions will be available through video track -} - -// handleWebSocketICECandidate handles ICE candidate -func (s *Server) handleWebSocketICECandidate(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - return - } - - candidateData, ok := msg["candidate"].(map[string]interface{}) - if !ok { - return - } - - bridge, exists := s.bridgeManager.GetBridge(deviceSerial) - if !exists { - return - } - - candidate := webrtc.ICECandidateInit{ - Candidate: candidateData["candidate"].(string), - } - - if sdpMLineIndex, ok := candidateData["sdpMLineIndex"].(float64); ok { - index := uint16(sdpMLineIndex) - candidate.SDPMLineIndex = &index - } - - if sdpMid, ok := candidateData["sdpMid"].(string); ok { - candidate.SDPMid = &sdpMid - } - - if err := bridge.WebRTCConn.AddICECandidate(candidate); err != nil { - log.Printf("Failed to add ICE candidate: %v", err) - } -} - -// handleWebSocketDisconnect handles disconnect message -func (s *Server) handleWebSocketDisconnect(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - return - } - - s.bridgeManager.RemoveBridge(deviceSerial) - - conn.WriteJSON(map[string]interface{}{ - "type": "disconnected", - }) -} - -// handleWebSocketTouch handles touch events -func (s *Server) handleWebSocketTouch(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - return - } - - bridge, exists := s.bridgeManager.GetBridge(deviceSerial) - if !exists { - log.Printf("Bridge not found for device %s", deviceSerial) - return - } - - bridge.HandleTouchEvent(msg) -} - -// handleWebSocketKey handles key events -func (s *Server) handleWebSocketKey(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - return - } - - bridge, exists := s.bridgeManager.GetBridge(deviceSerial) - if !exists { - log.Printf("Bridge not found for device %s", deviceSerial) - return - } - - bridge.HandleKeyEvent(msg) -} - -// handleWebSocketScroll handles scroll events -func (s *Server) handleWebSocketScroll(conn *websocket.Conn, msg map[string]interface{}) { - deviceSerial, ok := msg["deviceSerial"].(string) - if !ok { - return - } - - bridge, exists := s.bridgeManager.GetBridge(deviceSerial) - if !exists { - log.Printf("Bridge not found for device %s", deviceSerial) - return - } - - bridge.HandleScrollEvent(msg) -} diff --git a/packages/cli/internal/device_connect/device/manager.go b/packages/cli/internal/device_connect/device/manager.go deleted file mode 100644 index 12335e05..00000000 --- a/packages/cli/internal/device_connect/device/manager.go +++ /dev/null @@ -1,148 +0,0 @@ -package device - -import ( - "fmt" - "os/exec" - "strings" - "sync" -) - -// Manager manages Android devices -type Manager struct { - adbPath string - devices map[string]*DeviceInfo - mu sync.RWMutex -} - -// DeviceInfo contains device information -type DeviceInfo struct { - Serial string - State string - Model string - Manufacturer string - ConnectionType string - IsRegistered bool -} - -// NewManager creates a new device manager -func NewManager() *Manager { - adbPath, err := exec.LookPath("adb") - if err != nil { - adbPath = "adb" - } - - return &Manager{ - adbPath: adbPath, - devices: make(map[string]*DeviceInfo), - } -} - -// GetDevices returns list of connected Android devices -func (m *Manager) GetDevices() ([]map[string]interface{}, error) { - cmd := exec.Command(m.adbPath, "devices", "-l") - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to run adb devices: %w", err) - } - - lines := strings.Split(string(output), "\n") - devices := []map[string]interface{}{} - - for _, line := range lines[1:] { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - parts := strings.Fields(line) - if len(parts) < 2 { - continue - } - - serial := parts[0] - state := parts[1] - - if state != "device" { - continue - } - - device := map[string]interface{}{ - "id": serial, - "udid": serial, - "state": state, - "ro.serialno": serial, - "connectionType": "usb", - "isRegistrable": false, - } - - // Parse additional properties - if strings.Contains(line, "model:") { - if idx := strings.Index(line, "model:"); idx != -1 { - modelPart := line[idx+6:] - if spaceIdx := strings.Index(modelPart, " "); spaceIdx != -1 { - device["ro.product.model"] = modelPart[:spaceIdx] - } else { - device["ro.product.model"] = modelPart - } - } - } - - if strings.Contains(line, "device:") { - if idx := strings.Index(line, "device:"); idx != -1 { - devicePart := line[idx+7:] - if spaceIdx := strings.Index(devicePart, " "); spaceIdx != -1 { - device["ro.product.manufacturer"] = devicePart[:spaceIdx] - } - } - } - - if strings.Contains(serial, ":") { - device["connectionType"] = "ip" - } - - // Check if device is registered - m.mu.RLock() - if info, exists := m.devices[serial]; exists { - device["isRegistrable"] = info.IsRegistered - } - m.mu.RUnlock() - - devices = append(devices, device) - } - - return devices, nil -} - -// RegisterDevice marks a device as registered -func (m *Manager) RegisterDevice(serial string) { - m.mu.Lock() - defer m.mu.Unlock() - - if m.devices[serial] == nil { - m.devices[serial] = &DeviceInfo{ - Serial: serial, - } - } - m.devices[serial].IsRegistered = true -} - -// UnregisterDevice marks a device as unregistered -func (m *Manager) UnregisterDevice(serial string) { - m.mu.Lock() - defer m.mu.Unlock() - - if info, exists := m.devices[serial]; exists { - info.IsRegistered = false - } -} - -// IsDeviceRegistered checks if a device is registered -func (m *Manager) IsDeviceRegistered(serial string) bool { - m.mu.RLock() - defer m.mu.RUnlock() - - if info, exists := m.devices[serial]; exists { - return info.IsRegistered - } - return false -} \ No newline at end of file