diff --git a/.github/workflows/test-live-view.yml b/.github/workflows/test-live-view.yml new file mode 100644 index 00000000..087c0e9e --- /dev/null +++ b/.github/workflows/test-live-view.yml @@ -0,0 +1,58 @@ +name: Live View Tests + +on: + push: + branches: ["**"] + paths: + - "packages/live-view/**" + pull_request: + branches: ["**"] + paths: + - "packages/live-view/**" + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: packages/live-view/package.json + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + cache-dependency-path: packages/live-view/pnpm-lock.yaml + + - name: Install dependencies + run: | + cd packages/live-view + pnpm install --frozen-lockfile + + - name: Run lint + run: | + cd packages/live-view + pnpm lint + + - name: Run type-check + run: | + cd packages/live-view + pnpm type-check + + - name: Run tests + run: | + cd packages/live-view + pnpm test + + - name: Run build + run: | + cd packages/live-view + pnpm build diff --git a/.vscode/settings.json b/.vscode/settings.json index aeb5e5f7..616c4924 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,8 @@ "gbox", "KUBECFG", "kubeconfig", - "modelcontextprotocol" + "modelcontextprotocol", + "serialno" ], "python.analysis.extraPaths": [ "./packages/sdk/python" diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index 86b6272d..a2c275f0 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -5,4 +5,7 @@ gbox-darwin-* gbox-linux-* gbox-windows-* gbox -gbox-test \ No newline at end of file +gbox-test + +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 2c4ec53e..65e384bd 100644 --- a/packages/cli/Makefile +++ b/packages/cli/Makefile @@ -56,9 +56,33 @@ 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 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 + @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))..." + @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)" @@ -74,7 +98,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.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_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_create.go b/packages/cli/cmd/box_create.go index 200a3ceb..ed8efff7 100644 --- a/packages/cli/cmd/box_create.go +++ b/packages/cli/cmd/box_create.go @@ -8,25 +8,46 @@ import ( // NewBoxCreateCommand creates the parent command for box creation func NewBoxCreateCommand() *cobra.Command { + opts := &BoxCreateFromDeviceOptions{} + cmd := &cobra.Command{ Use: "create", Short: "Create a new box", Long: `Create a new box with various options for image, environment, and commands. Available box types: - linux - Create a Linux container box - android - Create an Android device box + linux - Create a Linux container box + android - Create an Android device box +Use '-d/--device-id' to create a box from an existing device by device ID. Use 'gbox box create --help' for more information about each type.`, Example: ` gbox box create linux --image python:3.9 -- python3 -c 'print("Hello")' gbox box create android --device-type virtual + gbox box create -d gbox box create linux --env PATH=/usr/local/bin:/usr/bin:/bin -w /app -- node server.js`, RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("please specify a box type: linux or android\nUse 'gbox box create --help' for more information") + // If device-id is provided, create box from device directly + if opts.DeviceID != "" { + return ExecuteBoxCreateFromDevice(cmd, opts) + } + return fmt.Errorf("please specify a box type: linux or android, or use -d/--device-id to create from device\nUse 'gbox box create --help' for more information") }, } + // Root-level flags to create from device directly + flags := cmd.Flags() + flags.StringVarP(&opts.DeviceID, "device-id", "d", "", "Device ID to create box from") + flags.BoolVarP(&opts.Force, "force", "f", true, "Force create box even if device is occupied") + flags.StringVarP(&opts.OutputFormat, "output", "o", "text", "Output format (json or text)") + + cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "text"}, cobra.ShellCompDirectiveNoFileComp + }) + // Add subcommands - cmd.AddCommand(NewBoxCreateLinuxCommand(), NewBoxCreateAndroidCommand()) + cmd.AddCommand( + NewBoxCreateLinuxCommand(), + NewBoxCreateAndroidCommand(), + ) return cmd } diff --git a/packages/cli/cmd/box_create_from_device.go b/packages/cli/cmd/box_create_from_device.go new file mode 100644 index 00000000..4fa8ee3b --- /dev/null +++ b/packages/cli/cmd/box_create_from_device.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/babelcloud/gbox/packages/cli/internal/cloud" + "github.com/spf13/cobra" +) + +type BoxCreateFromDeviceOptions struct { + DeviceID string + Force bool + OutputFormat string +} + +func NewBoxCreateFromDeviceCommand() *cobra.Command { + opts := &BoxCreateFromDeviceOptions{} + + cmd := &cobra.Command{ + Use: "from-device [device-id] [flags]", + Short: "Create a box from a device", + Long: `Create a box from an existing device by device ID. +This command allows you to create a box (Linux or Android) from a registered device. + +If force is true, any existing box using the device will be terminated.`, + Example: ` # Create a box from a device + gbox box create from-device + + # Create a box and force terminate any existing box + gbox box create from-device --force + + # Output in JSON format + gbox box create from-device --output json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.DeviceID = args[0] + } + return ExecuteBoxCreateFromDevice(cmd, opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&opts.DeviceID, "device-id", "d", "", "Device ID to create box from") + flags.BoolVarP(&opts.Force, "force", "f", true, "Force create box even if device is occupied") + flags.StringVarP(&opts.OutputFormat, "output", "o", "text", "Output format (json or text)") + + cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "text"}, cobra.ShellCompDirectiveNoFileComp + }) + + return cmd +} + +func ExecuteBoxCreateFromDevice(cmd *cobra.Command, opts *BoxCreateFromDeviceOptions) error { + if opts.DeviceID == "" { + return fmt.Errorf("device ID is required. Use --device-id or provide as argument") + } + + deviceAPI := cloud.NewDeviceAPI() + box, err := deviceAPI.DeviceToBox(opts.DeviceID, opts.Force) + if err != nil { + return fmt.Errorf("failed to create box from device: %v", err) + } + + // Output result + if opts.OutputFormat == "json" { + boxJSON, _ := json.MarshalIndent(box, "", " ") + fmt.Println(string(boxJSON)) + } else { + fmt.Printf("Box created successfully from device %s\n", opts.DeviceID) + fmt.Printf("Box ID: %s\n", box.Id) + if box.Type != "" { + fmt.Printf("Box Type: %s\n", box.Type) + } + if box.Status != "" { + fmt.Printf("Box Status: %s\n", box.Status) + } + } + + return nil +} diff --git a/packages/cli/cmd/box_list.go b/packages/cli/cmd/box_list.go index 09bdfe73..9dbd3c29 100644 --- a/packages/cli/cmd/box_list.go +++ b/packages/cli/cmd/box_list.go @@ -5,6 +5,7 @@ import ( "fmt" client "github.com/babelcloud/gbox/packages/cli/internal/client" + "github.com/babelcloud/gbox/packages/cli/internal/util" "github.com/spf13/cobra" ) @@ -130,12 +131,12 @@ func printResponse(resp interface{}, outputFormat string) error { } // Define table columns - columns := []TableColumn{ + columns := []util.TableColumn{ {Header: "ID", Key: "id"}, {Header: "TYPE", Key: "type"}, {Header: "STATUS", Key: "status"}, } - renderTable(columns, data) + util.RenderTable(columns, data) return nil } diff --git a/packages/cli/cmd/device_connect.go b/packages/cli/cmd/device_connect.go index f1709630..f97a1218 100644 --- a/packages/cli/cmd/device_connect.go +++ b/packages/cli/cmd/device_connect.go @@ -1,127 +1,42 @@ package cmd import ( - "archive/tar" - "compress/gzip" - "encoding/json" "fmt" - "io" - "net/http" "os" - "os/exec" "os/signal" "path/filepath" "runtime" "strings" "syscall" - "time" "github.com/fatih/color" "github.com/spf13/cobra" "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" ) -// printDeveloperModeHint prints the developer mode hint with dim formatting -func printDeveloperModeHint() { - color.New(color.Faint).Println("If you can not see your devices here, make sure you have turned on the developer mode on your Android device. For more details, see https://docs.gbox.ai/cli") -} - -func checkAdbInstalled() bool { - _, err := exec.LookPath("adb") - return err == nil -} - -func checkFrpcInstalled() bool { - _, err := exec.LookPath("frpc") - return err == nil -} - -func printAdbInstallationHint() { - const ( - ansiRed = "\033[31m" - ansiYellow = "\033[33m" - ansiBold = "\033[1m" - ansiReset = "\033[0m" - ) - - fmt.Println() - fmt.Printf("%s%s⚠️ IMPORTANT: Android Debug Bridge (ADB) Required%s\n", ansiRed, ansiBold, ansiReset) - fmt.Printf("%s%s================================================%s\n", ansiYellow, ansiBold, ansiReset) - fmt.Printf("%sTo use the device-connect feature, you need to install ADB tools first:%s\n", ansiYellow, ansiReset) - fmt.Println() - fmt.Printf("%s📱 Installation Methods:%s\n", ansiBold, ansiReset) - fmt.Printf(" • macOS: brew install android-platform-tools\n") - fmt.Printf(" • Ubuntu/Debian: sudo apt-get install android-tools-adb\n") - fmt.Printf(" • Windows: Download Android SDK Platform Tools\n") - fmt.Println() - fmt.Printf("%s🔗 After installation, ensure:%s\n", ansiBold, ansiReset) - fmt.Printf(" 1. Enable Developer Options and USB Debugging on your Android device\n") - fmt.Printf(" 2. Connect device via USB or start an emulator\n") - fmt.Printf(" 3. Run 'adb devices' to confirm device recognition\n") - fmt.Println() - fmt.Printf("%s%s================================================%s\n", ansiYellow, ansiBold, ansiReset) - fmt.Println() -} - -func printFrpcInstallationHint() { - const ( - ansiRed = "\033[31m" - ansiYellow = "\033[33m" - ansiBold = "\033[1m" - ansiReset = "\033[0m" - ) - - fmt.Println() - fmt.Printf("%s%s⚠️ IMPORTANT: FRP Client (frpc) Required%s\n", ansiRed, ansiBold, ansiReset) - fmt.Printf("%s%s==============================================%s\n", ansiYellow, ansiBold, ansiReset) - fmt.Printf("%sTo use the device-connect feature, you need to install frpc (FRP Client) first:%s\n", ansiYellow, ansiReset) - fmt.Println() - fmt.Printf("%s🌐 Installation Methods:%s\n", ansiBold, ansiReset) - fmt.Printf(" • macOS: brew install frpc\n") - fmt.Printf(" • Ubuntu/Debian: Download from https://github.com/fatedier/frp/releases\n") - fmt.Printf(" • Windows: Download from https://github.com/fatedier/frp/releases\n") - fmt.Println() - fmt.Printf("%s📥 Manual Installation:%s\n", ansiBold, ansiReset) - fmt.Printf(" 1. Download frpc binary for your platform from GitHub releases\n") - fmt.Printf(" 2. Extract and place frpc in your PATH or current directory\n") - fmt.Printf(" 3. Ensure frpc is executable: chmod +x frpc\n") - fmt.Println() - fmt.Printf("%s🔗 After installation, ensure:%s\n", ansiBold, ansiReset) - fmt.Printf(" 1. frpc is in your PATH or current directory\n") - fmt.Printf(" 2. Run 'frpc version' to confirm installation\n") - fmt.Println() - fmt.Printf("%s%s==============================================%s\n", ansiYellow, ansiBold, ansiReset) - fmt.Println() -} +// Note: Device client functionality has been moved to daemon.DefaultManager +// All device operations now go through the unified server API type DeviceConnectOptions struct { DeviceID string Background bool } -// 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 -} - func NewDeviceConnectCommand() *cobra.Command { opts := &DeviceConnectOptions{} cmd := &cobra.Command{ - Use: "device-connect [command] [flags]", - Short: "Manage remote connections for local Android development devices", - Long: `Manage remote connections for local Android development devices. + Use: "device-connect [device_id] [flags]", + Short: "Manage remote connections for local Android/Linux development devices", + Long: `Manage remote connections for local Android/Linux development devices. This command allows you to securely connect Android devices (emulators or physical devices) -to remote cloud services for remote access and debugging.`, +to remote cloud services for remote access and debugging. + +If no device ID is provided, an interactive device selection will be shown.`, RunE: func(cmd *cobra.Command, args []string) error { return ExecuteDeviceConnect(cmd, opts, args) }, @@ -129,37 +44,30 @@ to remote cloud services for remote access and debugging.`, gbox device-connect # Connect to specific device - gbox device-connect --device abc123xyz456-usb + gbox device-connect abc123xyz456-usb - # Connect device in background - gbox device-connect --device abc789pqr012-ip --background + # Connect in background mode + gbox device-connect --background # List all available devices gbox device-connect ls - # List devices in JSON format - 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`, + # Register and connect this Linux machine to AP + gbox device-connect register local`, } 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.StringVarP(&opts.DeviceID, "device", "d", "", "Specify the Android device ID to connect") + flags.BoolVarP(&opts.Background, "background", "b", false, "Run in background mode") cmd.AddCommand( + NewDeviceConnectRegisterCommand(), NewDeviceConnectListCommand(), NewDeviceConnectUnregisterCommand(), - NewDeviceConnectKillServerCommand(), ) return cmd } - func ExecuteDeviceConnect(cmd *cobra.Command, opts *DeviceConnectOptions, args []string) error { debug := os.Getenv("DEBUG") == "true" @@ -220,15 +128,26 @@ func ExecuteDeviceConnect(cmd *cobra.Command, opts *DeviceConnectOptions, args [ return err } - // 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 + + var deviceID string + if len(args) > 0 { + deviceID = args[0] + } else if opts.DeviceID != "" { + deviceID = opts.DeviceID } - if opts.DeviceID == "" { + if deviceID == "" { return runInteractiveDeviceSelection(opts) } - return connectToDevice(opts.DeviceID, opts) + return connectToDevice(deviceID, opts) } // checkAndInstallPrerequisites checks and installs Node.js, npm, Appium and related components @@ -305,62 +224,24 @@ func checkAndInstallPrerequisites() error { return nil } -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 +func runInteractiveDeviceSelection(opts *DeviceConnectOptions) error { + // Use daemon manager to call API + var response struct { + Success bool `json:"success"` + Devices []DeviceDTO `json:"devices"` } - // 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 + if err := daemon.DefaultManager.CallAPI("GET", "/api/devices", nil, &response); err != nil { + return fmt.Errorf("failed to get available devices: %v", err) } - // 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() + if !response.Success { + return fmt.Errorf("failed to get devices from server") } - return running, nil -} - -func runInteractiveDeviceSelection(opts *DeviceConnectOptions) error { - client := getDeviceClient() - devices, err := getDevicesWithValidation(client, 60*time.Second) - if err != nil { - return fmt.Errorf("failed to get available devices: %v", err) - } + devices := response.Devices if len(devices) == 0 { - fmt.Println("No Android devices found.") + fmt.Println("No devices found.") fmt.Println() printDeveloperModeHint() return nil @@ -372,20 +253,9 @@ func runInteractiveDeviceSelection(opts *DeviceConnectOptions) error { printDeveloperModeHint() fmt.Println() + // Display all devices returned from API for i, device := range devices { - status := "Not Registered" - statusColor := color.New(color.Faint) // 使用淡色(灰色) - if device.IsRegistrable { // Assuming IsRegistrable should be IsRegistered - status = "Registered" - statusColor = color.New(color.FgGreen) - } - 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, - statusColor.Sprint(status)) + formatDeviceOption(i+1, device) } fmt.Println() fmt.Print("Enter a number: ") @@ -405,399 +275,351 @@ func runInteractiveDeviceSelection(opts *DeviceConnectOptions) error { select { case <-intCh: - // User pressed Ctrl+C during selection; stop proxy first then exit gracefully - _ = executeKillServer() + // User pressed Ctrl+C during selection; exit gracefully return nil case <-inputDone: // proceed } - if choice < 1 || choice > len(devices) { + totalOptions := len(devices) + if choice < 1 || choice > totalOptions { return fmt.Errorf("invalid selection: %d", choice) } selectedDevice := devices[choice-1] - return connectToDevice(selectedDevice.Id, opts) -} -func connectToDevice(deviceID string, opts *DeviceConnectOptions) error { - client := getDeviceClient() - - device, err := getDeviceInfoWithRetry(client, deviceID, 60*time.Second) - if err != nil { - return fmt.Errorf("failed to get device info: %v", err) + // Handle local device registration + if selectedDevice.IsLocal { + // Use empty deviceID to register as desktop with auto-detected OS + // Server will automatically connect desktop devices after registration + return registerDevice("", "") } - 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 { - return fmt.Errorf("failed to register device: %v", err) + // For Android devices, use TransportID for API call, fallback to Serialno if empty + deviceID := selectedDevice.TransportID + if strings.TrimSpace(deviceID) == "" { + deviceID = selectedDevice.Serialno } + return connectToDevice(deviceID, opts) +} - fmt.Printf("Connection established successfully!\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)) - } - } +// formatDeviceOption formats a device for display in the interactive selection +func formatDeviceOption(index int, device DeviceDTO) { + status := "Not Registered" + statusColor := color.New(color.Faint) - if opts.Background { - fmt.Println("(Running in background. Use 'gbox device-connect unregister' to stop.)") - return nil + // If IsLocal=true, replace serialNo with "local" for display + displaySerialNo := device.Serialno + if device.IsLocal { + displaySerialNo = "local" } - 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 %s (%s, %s)...\n", - device.ProductModel, device.ConnectionType, device.ProductManufacturer) - - // First unregister the device - if err := client.UnregisterDevice(deviceID); err != nil { - fmt.Printf("Warning: failed to unregister device: %v\n", err) - } + isRegistered := device.IsRegistered - // 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) + if isRegistered { + status = "Registered" + statusColor = color.New(color.FgGreen) } - return nil -} - -// executeKillServer calls the existing kill-server functionality -func executeKillServer() error { - opts := &DeviceConnectKillServerOptions{ - Force: true, - All: false, + // Get device-specific fields from metadata + var model, manufacturer, connectionType string + if device.Metadata != nil { + if m, ok := device.Metadata["model"].(string); ok { + model = m + } + if m, ok := device.Metadata["manufacturer"].(string); ok { + manufacturer = m + } + if ct, ok := device.Metadata["connectionType"].(string); ok { + connectionType = ct + } } - // 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) -} -// getDeviceInfoWithRetry waits for the API to be ready and then looks up the device -func getDeviceInfoWithRetry(client *device_connect.Client, deviceID string, timeout time.Duration) (*device_connect.DeviceInfo, error) { - devices, err := getDevicesWithValidation(client, timeout) - if err != nil { - return nil, err - } - for _, d := range devices { - if d.Id == deviceID { - return &d, nil + if strings.TrimSpace(model) == "" { + model = "Unknown" + } + if strings.TrimSpace(manufacturer) == "" { + manufacturer = "Unknown" + } + + // Map Platform and OS to display label + var platformLabel string + if device.Platform == "mobile" && device.OS == "android" { + platformLabel = "Android" + } else if device.Platform == "desktop" { + // Map OS to display label for desktop + switch device.OS { + case "macos": + platformLabel = "MacOS" + case "linux": + platformLabel = "Linux" + case "windows": + platformLabel = "Windows" + default: + platformLabel = device.OS } + } else { + platformLabel = device.Platform } - return nil, fmt.Errorf("device not found: %s", deviceID) -} -// getDevicesWithValidation retries fetching devices until at least one device has a non-empty serial number, -// or until the timeout is reached. This avoids the abnormal "-usb" entries when the upstream is not ready. -func getDevicesWithValidation(client *device_connect.Client, timeout time.Duration) ([]device_connect.DeviceInfo, error) { - deadline := time.Now().Add(timeout) - var lastErr error - var lastDevices []device_connect.DeviceInfo - for { - devices, err := client.GetDevices() - if err == nil { - lastDevices = devices - if !hasAnyEmptySerial(devices) { - return devices, nil + // For local devices, get OS version and hostname for display from metadata + if device.IsLocal { + var osVersion string + var hostname string + if device.Metadata != nil { + if ov, ok := device.Metadata["osVersion"].(string); ok { + osVersion = ov + } + if hn, ok := device.Metadata["hostname"].(string); ok { + hostname = hn } - lastErr = fmt.Errorf("some devices missing serial numbers; retrying") - } else { - lastErr = err } - - if time.Now().After(deadline) { - if len(lastDevices) > 0 { - valid := filterValidDevices(lastDevices) - if len(valid) > 0 { - return valid, nil + if osVersion == "" { + // Fallback to runtime detection + switch runtime.GOOS { + case "linux": + if version, err := getLinuxVersion(); err == nil { + osVersion = version + } else { + osVersion = "Unknown" + } + case "darwin": + if version, err := getMacOSVersion(); err == nil { + osVersion = version + } else { + osVersion = "Unknown" } + case "windows": + if version, err := getWindowsVersion(); err == nil { + osVersion = version + } else { + osVersion = "Unknown" + } + default: + osVersion = "Unknown" } - return nil, lastErr - } - time.Sleep(1 * time.Second) - } -} - -// allSerialMissing returns true when every device has an empty SerialNo -func allSerialMissing(devices []device_connect.DeviceInfo) bool { - if len(devices) == 0 { - return false - } - for _, d := range devices { - if d.SerialNo != "" { - return false } - } - return true -} - -// hasAnyEmptySerial returns true if any device has an empty SerialNo -func hasAnyEmptySerial(devices []device_connect.DeviceInfo) bool { - for _, d := range devices { - if d.SerialNo == "" { - return true + // Use hostname from metadata, fallback to manufacturer if not available + if hostname == "" { + hostname = manufacturer } - } - return false -} - -// filterValidDevices returns only devices with a non-empty SerialNo -func filterValidDevices(devices []device_connect.DeviceInfo) []device_connect.DeviceInfo { - var result []device_connect.DeviceInfo - for _, d := range devices { - if d.SerialNo != "" { - result = append(result, d) + fmt.Printf("%d. %s (%s, %s) - %s [%s]\n", + index, + color.New(color.FgCyan).Sprint(displaySerialNo), + platformLabel, + osVersion, + hostname, + statusColor.Sprint(status)) + } else { + // Format: serialNo (Platform, model, connectionType) - manufacturer [status] + // For Android devices, connectionType is in metadata + if connectionType == "" { + connectionType = "unknown" } + fmt.Printf("%d. %s (%s, %s, %s) - %s [%s]\n", + index, + color.New(color.FgCyan).Sprint(displaySerialNo), + platformLabel, + model, + connectionType, + manufacturer, + statusColor.Sprint(status)) } - return result } -// runAsRoot executes a command with root privileges if needed -func runAsRoot(name string, args ...string) error { - // Check if already running as root (Unix-like systems) - if runtime.GOOS != "windows" { - cmd := exec.Command("id", "-u") - output, err := cmd.Output() - if err == nil && strings.TrimSpace(string(output)) == "0" { - // Already root, run directly - cmd := exec.Command(name, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - } +func connectToDevice(deviceID string, opts *DeviceConnectOptions) error { + // Register device via daemon API + // For Android devices + req := map[string]string{ + "deviceId": deviceID, + "deviceType": "mobile", + "osType": "android", } + var resp map[string]interface{} - // Check if sudo is available - if _, err := exec.LookPath("sudo"); err == nil { - // Use sudo - fullArgs := append([]string{name}, args...) - cmd := exec.Command("sudo", fullArgs...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/register", req, &resp); err != nil { + return fmt.Errorf("failed to register device: %v", err) } - // No sudo available, try running directly - cmd := exec.Command(name, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// installADB attempts to install ADB using the system package manager -func installADB() error { - if _, err := exec.LookPath("brew"); err == nil { - // macOS with Homebrew - cmd := exec.Command("brew", "install", "android-platform-tools") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + if success, ok := resp["success"].(bool); !ok || !success { + return fmt.Errorf("failed to register device: %v", resp["error"]) } - if _, err := exec.LookPath("apt-get"); err == nil { - // Debian/Ubuntu - return runAsRoot("apt-get", "install", "-y", "android-tools-adb") - } + fmt.Printf("Establishing remote connection for device %s...\n", deviceID) - if _, err := exec.LookPath("yum"); err == nil { - // RHEL/CentOS - return runAsRoot("yum", "install", "-y", "android-tools") - } + fmt.Printf("Connection established successfully!\n") - return fmt.Errorf("unable to detect package manager") -} + // 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") -// installFrpc attempts to install frpc using the system package manager or GitHub releases -func installFrpc() error { - // Try Homebrew first on macOS - if runtime.GOOS == "darwin" { - if _, err := exec.LookPath("brew"); err == nil { - cmd := exec.Command("brew", "install", "frpc") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err == nil { - return nil - } - // If brew fails, fall through to GitHub installation + // 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)) } } - // Download from GitHub releases for all platforms - return installFrpcFromGitHub() -} - -// installFrpcFromGitHub downloads and installs frpc from GitHub releases -func installFrpcFromGitHub() error { - // Get latest frpc version from GitHub API - resp, err := http.Get("https://api.github.com/repos/fatedier/frp/releases/latest") - if err != nil { - return fmt.Errorf("failed to fetch frpc version: %v", err) + if opts.Background { + fmt.Println("(Running in background. Use 'gbox device-connect unregister' to stop.)") + return nil } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to fetch frpc version: HTTP %d", resp.StatusCode) - } + fmt.Printf("(Running in foreground. Press %s to disconnect.)\n", color.New(color.FgYellow, color.Bold).Sprint("Ctrl+C")) - var release struct { - TagName string `json:"tag_name"` - } + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return fmt.Errorf("failed to parse release info: %v", err) - } + <-sigChan + fmt.Printf("Disconnecting device %s...\n", deviceID) - // Remove 'v' prefix from version - frpcVersion := strings.TrimPrefix(release.TagName, "v") - if frpcVersion == "" { - return fmt.Errorf("invalid version tag: %s", release.TagName) + // 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) } - // Detect OS and architecture - osType := runtime.GOOS - archType := runtime.GOARCH + return nil +} - // Map architecture names to frp naming convention - switch archType { - case "amd64": - // Keep as is - case "arm64": - // Keep as is - case "arm": - // Keep as is - default: - return fmt.Errorf("unsupported architecture: %s", archType) +// registerDevice registers a device for remote access +// If deviceID is empty and deviceType is empty, register as desktop with auto-detected OS +// If deviceID is provided, register as mobile (Android) device +// If deviceType is provided (for backward compatibility), use it to determine type +func registerDevice(deviceID string, deviceType string) error { + // Register device via daemon API + req := make(map[string]string) + isDesktop := false + + // Determine device type based on parameters + if deviceID == "" && deviceType == "" { + // Empty deviceID and deviceType means register local machine as desktop + req["deviceType"] = "desktop" + isDesktop = true + // Auto-detect OS type + switch runtime.GOOS { + case "linux": + req["osType"] = "linux" + case "darwin": + req["osType"] = "macos" + case "windows": + req["osType"] = "windows" + default: + req["osType"] = "linux" // Default fallback + } + } else if deviceType != "" { + // Backward compatibility: use provided deviceType + oldType := strings.ToLower(deviceType) + if oldType == "android" { + req["deviceType"] = "mobile" + req["osType"] = "android" + } else if oldType == "linux" { + req["deviceType"] = "desktop" + req["osType"] = "linux" + isDesktop = true + } else { + // Default: treat as desktop and try to detect OS + req["deviceType"] = "desktop" + isDesktop = true + switch runtime.GOOS { + case "linux": + req["osType"] = "linux" + case "darwin": + req["osType"] = "macos" + case "windows": + req["osType"] = "windows" + default: + req["osType"] = "linux" // Default fallback + } + } + } else { + // deviceID is provided, register as mobile (Android) device + req["deviceType"] = "mobile" + req["osType"] = "android" } - // Construct download URL - downloadURL := fmt.Sprintf( - "https://github.com/fatedier/frp/releases/download/v%s/frp_%s_%s_%s.tar.gz", - frpcVersion, frpcVersion, osType, archType, - ) - - // Create temporary directory for frpc binary only - tempDir, err := os.MkdirTemp("", "frpc-install-*") - if err != nil { - return fmt.Errorf("failed to create temp directory: %v", err) + // For desktop devices, try to reuse regId if deviceID is empty + if isDesktop && deviceID == "" { + if regId, _ := readLocalRegId(); regId != "" { + req["regId"] = regId + } } - defer os.RemoveAll(tempDir) - // Download and stream-extract in one pass - resp, err = http.Get(downloadURL) - if err != nil { - return fmt.Errorf("failed to download frpc: %v", err) + if deviceID != "" { + req["deviceId"] = deviceID } - defer resp.Body.Close() + var resp map[string]interface{} - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to download frpc: HTTP %d", resp.StatusCode) + if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/register", req, &resp); err != nil { + return fmt.Errorf("failed to register device: %v", err) } - // Create gzip reader directly from HTTP response body (no intermediate file) - gzr, err := gzip.NewReader(resp.Body) - if err != nil { - return fmt.Errorf("failed to create gzip reader: %v", err) + if success, ok := resp["success"].(bool); !ok || !success { + return fmt.Errorf("failed to register device: %v", resp["error"]) } - defer gzr.Close() - - // Create tar reader from gzip stream - tr := tar.NewReader(gzr) - // Extract frpc binary directly from stream - var frpcBinaryPath string - for { - header, err := tr.Next() - if err == io.EOF { - break + // Resolve actual device ID and regId from response + actualID := deviceID + regIdStr := "" + if data, ok := resp["data"].(map[string]interface{}); ok { + if id, ok2 := data["id"].(string); ok2 && id != "" { + actualID = id } - if err != nil { - return fmt.Errorf("failed to read tar entry: %v", err) + if rid, ok2 := data["regId"].(string); ok2 && rid != "" { + regIdStr = rid + } else if actualID != "" { + regIdStr = actualID } - - // Look for frpc binary - if filepath.Base(header.Name) == "frpc" && header.Typeflag == tar.TypeReg { - frpcBinaryPath = filepath.Join(tempDir, "frpc") - - // Create file with proper permissions - outFile, err := os.OpenFile(frpcBinaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) - if err != nil { - return fmt.Errorf("failed to create frpc binary: %v", err) - } - - // Stream-copy from tar to file - if _, err := io.Copy(outFile, tr); err != nil { - outFile.Close() - return fmt.Errorf("failed to extract frpc binary: %v", err) - } - - if err := outFile.Close(); err != nil { - return fmt.Errorf("failed to close frpc binary: %v", err) + } + // Also check top-level fields + if regIdStr == "" { + if v, ok := resp["regId"]; ok { + if rid, ok2 := v.(string); ok2 && rid != "" { + regIdStr = rid } - - // Found and extracted, stop processing archive - break } } - - if frpcBinaryPath == "" { - return fmt.Errorf("frpc binary not found in archive") + if actualID != "" && deviceID == "" { + deviceID = actualID } - // Install to system location - installPath := "/usr/local/bin/frpc" - if err := installBinaryWithSudo(frpcBinaryPath, installPath); err != nil { - return fmt.Errorf("failed to install frpc to %s: %v", installPath, err) + // For desktop devices, persist regId for future reuse + if isDesktop && regIdStr != "" { + _ = writeLocalRegId(regIdStr) } - return nil -} - -// installBinaryWithSudo installs a binary to the system location, using sudo if necessary -func installBinaryWithSudo(src, dst string) error { - // Try direct copy first (works if we have write permission) - if err := copyBinaryFile(src, dst); err == nil { - return nil - } - - // If direct copy fails, use install command with runAsRoot (Unix-like systems) - if runtime.GOOS != "windows" { - if err := runAsRoot("install", "-m", "755", src, dst); err != nil { - return fmt.Errorf("install with elevated privileges failed: %v", err) + // Display registration result + if isDesktop { + if actualID != "" && regIdStr != "" { + fmt.Printf("Desktop device registered. Device ID: %s (regId: %s)\n", actualID, regIdStr) + } else if actualID != "" { + fmt.Printf("Desktop device registered. Device ID: %s\n", actualID) + } else { + fmt.Printf("Desktop device registered.\n") + } + } else { + if actualID != "" { + fmt.Printf("Device registered. Device ID: %s\n", actualID) } - return nil } - return fmt.Errorf("permission denied and elevated privileges not available") -} + fmt.Printf("Establishing remote connection for device %s...\n", deviceID) + fmt.Printf("Connection established successfully!\n") -// copyBinaryFile copies a binary file with executable permissions -func copyBinaryFile(src, dst string) error { - srcFile, err := os.Open(src) - if err != nil { - return fmt.Errorf("failed to open source: %v", err) - } - defer srcFile.Close() + // 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") - dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) - if err != nil { - return fmt.Errorf("failed to create destination: %v", err) + // 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)) + } } - defer dstFile.Close() - if _, err := io.Copy(dstFile, srcFile); err != nil { - return fmt.Errorf("failed to copy: %v", err) - } + fmt.Printf("\n💡 Device registered successfully. Use 'gbox device-connect unregister %s' to disconnect when needed.\n", deviceID) return nil } 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 5e528b32..00000000 --- a/packages/cli/cmd/device_connect_kill_server.go +++ /dev/null @@ -1,258 +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) - } - - // Use port and name-based killing when --all flag is set, or when PID kill did not suffice - 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, verify no device proxy remains; if remains, perform a final forced cleanup sweep - remainingPortProcesses, _ := device_connect.FindProcessesOnPort(device_connect.DefaultPort) - remainingNameProcesses, _ := device_connect.FindGboxDeviceProxyProcesses() - stillRunning := false - for _, pid := range remainingPortProcesses { - if device_connect.IsDeviceProxyProcess(pid) { - stillRunning = true - break - } - } - if !stillRunning && len(remainingNameProcesses) == 0 { - fmt.Println("Device proxy service stopped successfully.") - return nil - } - - // Final sweep: force kill anything that looks like device-proxy - for _, pid := range remainingPortProcesses { - if device_connect.IsDeviceProxyProcess(pid) { - _ = device_connect.KillProcess(pid, true) - } - } - for _, pid := range remainingNameProcesses { - _ = device_connect.KillProcess(pid, true) - } - - // Re-check - finalPortProcesses, _ := device_connect.FindProcessesOnPort(device_connect.DefaultPort) - finalNameProcesses, _ := device_connect.FindGboxDeviceProxyProcesses() - var finalDeviceProxyPorts []int - for _, pid := range finalPortProcesses { - if device_connect.IsDeviceProxyProcess(pid) { - finalDeviceProxyPorts = append(finalDeviceProxyPorts, pid) - } - } - if len(finalDeviceProxyPorts) == 0 && len(finalNameProcesses) == 0 { - fmt.Println("Device proxy service stopped successfully.") - return nil - } - - fmt.Println("Warning: Some device proxy processes may still be running:") - if len(finalDeviceProxyPorts) > 0 { - fmt.Printf(" Device proxy processes using port %d:\n", device_connect.DefaultPort) - for _, pid := range finalDeviceProxyPorts { - if cmd, err := device_connect.GetProcessCommand(pid); err == nil { - fmt.Printf(" PID %d: %s\n", pid, cmd) - } else { - fmt.Printf(" PID %d: \n", pid) - } - } - } - if len(finalNameProcesses) > 0 { - fmt.Println(" Device proxy processes found by name:") - for _, pid := range finalNameProcesses { - 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("Try 'gbox device-connect kill-server --all --force' if they persist.") - return nil - } -} diff --git a/packages/cli/cmd/device_connect_list.go b/packages/cli/cmd/device_connect_list.go index 59b9df21..e39415fd 100644 --- a/packages/cli/cmd/device_connect_list.go +++ b/packages/cli/cmd/device_connect_list.go @@ -3,24 +3,45 @@ package cmd import ( "encoding/json" "fmt" + "sort" "strings" - "time" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect" + "github.com/babelcloud/gbox/packages/cli/internal/daemon" + "github.com/babelcloud/gbox/packages/cli/internal/util" "github.com/spf13/cobra" ) const ( + statusConnected = "Connected" + statusReconnecting = "Reconnecting" + statusDisconnected = "Disconnected" statusRegistered = "Registered" statusNotRegistered = "Not Registered" - deviceTypeDevice = "device" - deviceTypeEmulator = "emulator" ) type DeviceConnectListOptions struct { OutputFormat string } +// DeviceDTO is the API response structure for devices +type DeviceDTO struct { + ID string `json:"id"` + TransportID string `json:"transportId"` + Serialno string `json:"serialno"` + AndroidID string `json:"androidId"` + Platform string `json:"platform"` // mobile, desktop + OS string `json:"os"` // android, linux, windows, macos + DeviceType string `json:"deviceType"` // physical, emulator, vm + IsRegistered bool `json:"isRegistered"` + IsConnected bool `json:"isConnected"` // true if device is currently connected to AP + IsReconnecting bool `json:"isReconnecting"` // true if device is attempting to reconnect + ReconnectAttempt int `json:"reconnectAttempt"` // Current reconnection attempt count + ReconnectMaxRetry int `json:"reconnectMaxRetry"` // Maximum reconnection attempts + RegId string `json:"regId"` + IsLocal bool `json:"isLocal"` // true if this is the local desktop device + Metadata map[string]interface{} `json:"metadata"` // Device-specific metadata +} + func NewDeviceConnectListCommand() *cobra.Command { opts := &DeviceConnectListOptions{} @@ -32,15 +53,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 @@ -52,64 +73,38 @@ func NewDeviceConnectListCommand() *cobra.Command { func ExecuteDeviceConnectList(cmd *cobra.Command, opts *DeviceConnectListOptions) error { if !checkAdbInstalled() { printAdbInstallationHint() - return fmt.Errorf("ADB is not installed or not in your PATH; please install ADB and try again") + 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") + 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) + // Use daemon manager to call unified server API + var response struct { + Success bool `json:"success"` + Devices []DeviceDTO `json:"devices"` } - client := getDeviceClient() - - devices, err := getDevicesWithValidation(client, 60*time.Second) - 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 opts.OutputFormat == "json" { - return outputDevicesJSON(devices) + if !response.Success { + return fmt.Errorf("failed to get devices from server") } - return outputDevicesText(devices) -} - -func outputDevicesJSON(devices []device_connect.DeviceInfo) error { - // Create a simplified JSON output for compatibility - type SimpleDeviceInfo struct { - DeviceID string `json:"device_id"` - Name string `json:"name"` - Type string `json:"type"` - ConnectionStatus string `json:"connection_status"` + if opts.OutputFormat == "json" { + return outputDevicesJSONFromAPI(response.Devices) } - var simpleDevices []SimpleDeviceInfo - for _, device := range devices { - status := statusNotRegistered - if device.IsRegistrable { - status = statusRegistered - } - - deviceType := deviceTypeDevice - // Check if it's an emulator based on serial number - if strings.Contains(strings.ToUpper(device.SerialNo), "EMULATOR") { - deviceType = deviceTypeEmulator - } - - simpleDevices = append(simpleDevices, SimpleDeviceInfo{ - DeviceID: device.Id, - Name: device.ProductModel, - Type: deviceType, - ConnectionStatus: status, - }) - } + return outputDevicesTextFromAPI(response.Devices) +} - jsonBytes, err := json.MarshalIndent(simpleDevices, "", " ") +func outputDevicesJSONFromAPI(devices []DeviceDTO) error { + // Output full DeviceDTO with all fields + jsonBytes, err := json.MarshalIndent(devices, "", " ") if err != nil { return fmt.Errorf("failed to marshal devices to JSON: %v", err) } @@ -117,66 +112,158 @@ func outputDevicesJSON(devices []device_connect.DeviceInfo) error { return nil } -func outputDevicesText(devices []device_connect.DeviceInfo) error { +func outputDevicesTextFromAPI(devices []DeviceDTO) error { if len(devices) == 0 { - fmt.Println("No Android devices found.") + fmt.Println("No devices found.") return nil } - // Calculate column widths based on content - deviceIDWidth := len("DEVICE ID") - nameWidth := len("NAME") - typeWidth := len("TYPE") - statusWidth := len("STATUS") - - // Find maximum widths for each column + // Build rows for sorting + type row struct { + serial string + deviceID string + serialOrTransport string + os string + deviceType string + status string + } + rows := make([]row, 0, len(devices)) for _, device := range devices { - if len(device.Id) > deviceIDWidth { - deviceIDWidth = len(device.Id) + deviceID := device.ID + serialNo := device.Serialno + transportID := device.TransportID + isRegistered := device.IsRegistered + isConnected := device.IsConnected + isReconnecting := device.IsReconnecting + reconnectAttempt := device.ReconnectAttempt + maxRetry := device.ReconnectMaxRetry + + // Determine status based on connection state + var status string + if isConnected { + // Green for Connected + status = "\x1b[32m" + statusConnected + "\x1b[0m" + } else if isReconnecting { + // Cyan for Reconnecting (with attempt info) + reconnectInfo := fmt.Sprintf("%s (%d/%d)", statusReconnecting, reconnectAttempt, maxRetry) + status = "\x1b[36m" + reconnectInfo + "\x1b[0m" + } else if isRegistered && reconnectAttempt >= maxRetry && maxRetry > 0 { + // Red for Disconnected (max retries reached) + status = "\x1b[31m" + statusDisconnected + "\x1b[0m" + } else if isRegistered { + // Yellow for Registered but not connected + status = "\x1b[33m" + statusRegistered + "\x1b[0m" + } else { + status = statusNotRegistered } - if len(device.ProductModel) > nameWidth { - nameWidth = len(device.ProductModel) + + // Get OS and DeviceType from device + os := device.OS + if os == "" { + os = "-" } - if len(deviceTypeEmulator) > typeWidth { - typeWidth = len(deviceTypeEmulator) + + // Get osVersion from metadata and combine with OS + osVersion := "" + if device.Metadata != nil { + if ov, ok := device.Metadata["osVersion"].(string); ok && ov != "" { + osVersion = ov + } } - if len(statusNotRegistered) > statusWidth { - statusWidth = len(statusNotRegistered) + + // Format OS display: capitalize first letter and handle macOS + osDisplay := os + if os != "-" && os != "" { + osLower := strings.ToLower(os) + switch osLower { + case "android": + osDisplay = "Android" + case "macos": + osDisplay = "MacOS" + case "linux": + osDisplay = "Linux" + case "windows": + osDisplay = "Windows" + default: + // Capitalize first letter + if len(os) > 0 { + osDisplay = strings.ToUpper(os[:1]) + strings.ToLower(os[1:]) + } + } + + // Append version if available + if osVersion != "" { + osDisplay = fmt.Sprintf("%s %s", osDisplay, osVersion) + } } - } - // Add some padding - deviceIDWidth += 2 - nameWidth += 2 - typeWidth += 2 - statusWidth += 2 + deviceType := device.DeviceType + if deviceType == "" { + deviceType = "-" + } - // Print header - fmt.Printf("%-*s %-*s %-*s %-*s\n", - deviceIDWidth, "DEVICE ID", - nameWidth, "NAME", - typeWidth, "TYPE", - statusWidth, "STATUS") + // Get connectionType from metadata for Android devices + connectionType := "" + if device.Metadata != nil { + if ct, ok := device.Metadata["connectionType"].(string); ok { + connectionType = ct + } + } - // Print data rows - for _, device := range devices { - status := statusNotRegistered - if device.IsRegistrable { - status = statusRegistered + // Second column: for USB show Serial No; otherwise show Transport ID (full value) + serialOrTransport := transportID + if strings.EqualFold(connectionType, "usb") && strings.TrimSpace(serialNo) != "" { + serialOrTransport = serialNo + } + + // DEVICE ID should be the remote cloud device ID. Fallback to "-" when empty + uniqueDeviceID := deviceID + if strings.TrimSpace(uniqueDeviceID) == "" { + uniqueDeviceID = "-" } - deviceType := deviceTypeDevice - // Check if it's an emulator based on serial number - if strings.Contains(strings.ToUpper(device.SerialNo), "EMULATOR") { - deviceType = deviceTypeEmulator + rows = append(rows, row{ + serial: device.Serialno, + deviceID: uniqueDeviceID, + serialOrTransport: serialOrTransport, + os: osDisplay, + deviceType: deviceType, + status: status, + }) + } + + // Sort: by real Serial No first, then by deviceID, then by serial/transport + sort.Slice(rows, func(i, j int) bool { + if rows[i].serial != rows[j].serial { + return rows[i].serial < rows[j].serial + } + if rows[i].deviceID != rows[j].deviceID { + return rows[i].deviceID < rows[j].deviceID } + return rows[i].serialOrTransport < rows[j].serialOrTransport + }) + + // Prepare data for RenderTable in sorted order + tableData := make([]map[string]interface{}, len(rows)) + for i, r := range rows { + tableData[i] = map[string]interface{}{ + "device_id": r.deviceID, + "serial_or_transport": r.serialOrTransport, + "os": r.os, + "device_type": r.deviceType, + "status": r.status, + } + } - fmt.Printf("%-*s %-*s %-*s %-*s\n", - deviceIDWidth, device.Id, - nameWidth, device.ProductModel, - typeWidth, deviceType, - statusWidth, status) + // Define table columns + columns := []util.TableColumn{ + {Header: "DEVICE ID", Key: "device_id"}, + {Header: "SERIAL NO/TRANSPORT ID", Key: "serial_or_transport"}, + {Header: "OS", Key: "os"}, + {Header: "DEVICE TYPE", Key: "device_type"}, + {Header: "STATUS", Key: "status"}, } + util.RenderTable(columns, tableData) return nil } diff --git a/packages/cli/cmd/device_connect_register.go b/packages/cli/cmd/device_connect_register.go new file mode 100644 index 00000000..79acaa6e --- /dev/null +++ b/packages/cli/cmd/device_connect_register.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/babelcloud/gbox/packages/cli/internal/daemon" + "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 a device for remote access", + Long: "Register a device for remote access. Use 'local' to register this machine as desktop, or provide a device ID to register an Android device.", + Example: ` # Register an Android device by ID + gbox device-connect register abc123xyz456 + + # Register and connect this machine as desktop + gbox device-connect register local`, + Args: cobra.MaximumNArgs(1), + SilenceUsage: false, + SilenceErrors: true, // Don't show errors twice (we handle them in RunE) + RunE: func(cmd *cobra.Command, args []string) error { + // No interactive mode - require device ID + if len(args) == 0 && opts.DeviceID == "" { + return fmt.Errorf("device ID is required. Use 'gbox device-connect' for interactive selection") + } + err := ExecuteDeviceConnectRegister(cmd, opts, args) + if err != nil { + fmt.Fprintln(cmd.ErrOrStderr(), err) + } + return nil // Return nil to prevent Cobra from printing again + }, + } + + flags := cmd.Flags() + flags.StringVarP(&opts.DeviceID, "device", "d", "", "Specify the device ID to register") + + return cmd +} + +func ExecuteDeviceConnectRegister(cmd *cobra.Command, opts *DeviceConnectRegisterOptions, args []string) error { + // Resolve device ID from args/flags first + var deviceID string + if len(args) > 0 { + deviceID = args[0] + } else if opts.DeviceID != "" { + deviceID = opts.DeviceID + } + + // Determine device type based on deviceID + // If "local", register as desktop with auto-detected OS + // Otherwise, register as mobile (Android) device + if strings.EqualFold(deviceID, "local") { + // For local registration, use empty deviceID and register as desktop + // OS type will be auto-detected by registerDevice + return registerDevice("", "") + } + + // For Android device, ensure ADB is installed + if !checkAdbInstalled() { + printAdbInstallationHint() + return fmt.Errorf("adb is not installed or not in your PATH; install adb and try again") + } + + // Register as mobile (Android) device + return registerDevice(deviceID, "android") +} + +// registerDevice is defined in device_connect.go (same package) + +type DeviceConnectLinuxConnectOptions struct { + DeviceID string +} + +// ExecuteDeviceConnectLinuxConnect is deprecated: use registerDevice with type="linux" instead +// This function is kept for backward compatibility but now calls registerDevice +func ExecuteDeviceConnectLinuxConnect(cmd *cobra.Command, opts *DeviceConnectLinuxConnectOptions, args []string) error { + // Force restart local server on each execution of this command + _ = daemon.DefaultManager.StopServer() + if err := daemon.DefaultManager.StartServer(); err != nil { + return fmt.Errorf("failed to restart local server: %v", err) + } + + // Use registerDevice which handles both registration and connection for desktop devices + deviceID := "" + if opts.DeviceID != "" && !strings.EqualFold(opts.DeviceID, "local") { + deviceID = opts.DeviceID + } + // Empty deviceID and deviceType will register as desktop with auto-detected OS + return registerDevice(deviceID, "") +} + +// getLocalRegIdPath returns the file path for storing the reg_id on this machine. +func getLocalRegIdPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".gbox") + return filepath.Join(dir, "reg_id"), nil +} + +// readLocalRegId reads reg_id from ~/.gbox/reg_id if exists. +func readLocalRegId() (string, error) { + path, err := getLocalRegIdPath() + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + // Trim trailing spaces/newlines + s := strings.TrimSpace(string(data)) + return s, nil +} + +// writeLocalRegId writes reg_id into ~/.gbox/reg_id, creating directory if needed. +func writeLocalRegId(regId string) error { + path, err := getLocalRegIdPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(regId+"\n"), 0o600) +} diff --git a/packages/cli/cmd/device_connect_unregister.go b/packages/cli/cmd/device_connect_unregister.go index 2ced1fd9..cf2c056a 100644 --- a/packages/cli/cmd/device_connect_unregister.go +++ b/packages/cli/cmd/device_connect_unregister.go @@ -2,8 +2,9 @@ package cmd import ( "fmt" + "strings" - "github.com/babelcloud/gbox/packages/cli/internal/device_connect" + "github.com/babelcloud/gbox/packages/cli/internal/daemon" "github.com/spf13/cobra" ) @@ -15,18 +16,28 @@ func NewDeviceConnectUnregisterCommand() *cobra.Command { opts := &DeviceConnectUnregisterOptions{} cmd := &cobra.Command{ - Use: "unregister [device_id] [flags]", + Use: "unregister [serial_or_transport_id|local] [flags]", Aliases: []string{"unreg"}, Short: "Unregister one or all active gbox device connections", - Long: "Unregister one or all active gbox device connections.", - Example: ` # Unregister device with specific device ID: - gbox device-connect unregister abc789pqr012-ip + Long: "Unregister one or all active gbox device connections. Use 'local' to unregister this machine.", + Example: ` # Unregister device with Serial No or Transport ID: + gbox device-connect unregister A4RYVB3A20008848 + gbox device-connect unregister adb-A4RYVB3A20008848._adb._tcp + + # Unregister local machine: + gbox device-connect unregister local # Unregister all active device connections: gbox device-connect unregister --all`, - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, // Don't show usage on errors (e.g., device not found) + SilenceErrors: true, // Don't show errors twice (we handle them in RunE) RunE: func(cmd *cobra.Command, args []string) error { - return ExecuteDeviceConnectUnregister(cmd, opts, args) + err := ExecuteDeviceConnectUnregister(cmd, opts, args) + if err != nil { + fmt.Fprintln(cmd.ErrOrStderr(), err) + } + return nil // Return nil to prevent Cobra from printing again }, } @@ -37,16 +48,6 @@ func NewDeviceConnectUnregisterCommand() *cobra.Command { } func ExecuteDeviceConnectUnregister(cmd *cobra.Command, opts *DeviceConnectUnregisterOptions, args []string) error { - if !checkAdbInstalled() { - printAdbInstallationHint() - 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() } @@ -55,30 +56,71 @@ func ExecuteDeviceConnectUnregister(cmd *cobra.Command, opts *DeviceConnectUnreg return runInteractiveUnregisterSelection() } - deviceID := args[0] - return unregisterDevice(deviceID) + deviceKey := args[0] + + // For "local" device, we don't need ADB check + if strings.EqualFold(deviceKey, "local") { + return unregisterLocalDevice() + } + + // For Android devices, check ADB + if !checkAdbInstalled() { + printAdbInstallationHint() + return fmt.Errorf("ADB is not installed or not in your PATH, please install ADB and try again") + } + + return unregisterDevice(deviceKey) } +// DeviceDTO is defined in device_connect_list.go (same package) + func unregisterAllDevices() error { - client := getDeviceClient() + // Get devices from daemon manager + var response struct { + Success bool `json:"success"` + Devices []DeviceDTO `json:"devices"` + } - 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") + } + unregisteredCount := 0 - for _, device := range devices { - if device.IsRegistrable { - fmt.Printf("Unregistering %s (%s, %s)...\n", - device.Udid, device.ProductModel, device.ConnectionType) + for _, device := range response.Devices { + if device.IsRegistered { + // Prefer transport id for API + deviceKey := device.TransportID + if deviceKey == "" { + deviceKey = device.Serialno + } + // Get device-specific fields from metadata + var name, connectionType string + if device.Metadata != nil { + if m, ok := device.Metadata["model"].(string); ok { + name = m + } + if ct, ok := device.Metadata["connectionType"].(string); ok { + connectionType = ct + } + } + if name == "" { + name = "Unknown" + } + if connectionType == "" { + connectionType = "unknown" + } - if err := client.UnregisterDevice(device.Udid); err != nil { - fmt.Printf("Failed to unregister %s: %v\n", device.Udid, err) + fmt.Printf("Unregistering %s (%s, %s)...\n", deviceKey, name, connectionType) + req := map[string]string{"deviceId": deviceKey} + if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/unregister", req, nil); err != nil { + fmt.Printf("Failed to unregister %s: %v\n", deviceKey, err) continue } - - fmt.Printf("Device %s unregistered successfully.\n", device.Udid) + fmt.Printf("Device %s unregistered successfully.\n", deviceKey) unregisteredCount++ } } @@ -92,38 +134,112 @@ func unregisterAllDevices() error { return nil } -func unregisterDevice(deviceID string) error { - client := getDeviceClient() +// unregisterLocalDevice unregisters the local desktop device using saved regId +func unregisterLocalDevice() error { + // Read regId from local file + regId, err := readLocalRegId() + if err != nil || regId == "" { + return fmt.Errorf("local device not registered or regId not found, use 'gbox device-connect register local' to register first") + } + + fmt.Printf("Unregistering local device (regId: %s)...\n", regId) - device, err := client.GetDeviceInfo(deviceID) - if err != nil { - return fmt.Errorf("device not found: %s", deviceID) + // Use regId as deviceId - the server will resolve it to actual device ID + req := map[string]string{"deviceId": regId} + if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/unregister", req, nil); err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "failed to resolve device identifiers") || strings.Contains(errMsg, "status 404") { + fmt.Println("Local device is not currently registered.") + _ = writeLocalRegId("") + return nil + } + return fmt.Errorf("failed to unregister local device: %v", err) } - fmt.Printf("Unregistering %s (%s, %s)...\n", - deviceID, device.ProductModel, device.ConnectionType) + // Clear the saved regId after successful unregistration + _ = writeLocalRegId("") + + fmt.Printf("Local device unregistered successfully.\n") + return nil +} + +func unregisterDevice(deviceKey string) error { + // Get device info first to show details + var response struct { + Success bool `json:"success"` + Devices []DeviceDTO `json:"devices"` + } - if err := client.UnregisterDevice(deviceID); 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") + } + + // Find the device to get its details + var target *DeviceDTO + for i := range response.Devices { + d := &response.Devices[i] + if deviceKey == d.TransportID || deviceKey == d.Serialno { + target = d + break + } + } + + if target == nil { + return fmt.Errorf("device not found: %s", deviceKey) + } + + // Get device-specific fields from metadata + var model, connectionType string + if target.Metadata != nil { + if m, ok := target.Metadata["model"].(string); ok { + model = m + } + if ct, ok := target.Metadata["connectionType"].(string); ok { + connectionType = ct + } + } + if model == "" { + model = "Unknown" + } + if connectionType == "" { + connectionType = "unknown" + } + + fmt.Printf("Unregistering %s (%s, %s)...\n", deviceKey, model, connectionType) + + req := map[string]string{"deviceId": deviceKey} + if err := daemon.DefaultManager.CallAPI("POST", "/api/devices/unregister", req, nil); err != nil { return fmt.Errorf("failed to unregister device: %v", err) } - fmt.Printf("Device %s unregistered successfully.\n", deviceID) + fmt.Printf("Device %s unregistered successfully.\n", deviceKey) return nil } func runInteractiveUnregisterSelection() error { - client := getDeviceClient() + // Get devices from daemon manager + var response struct { + Success bool `json:"success"` + Devices []DeviceDTO `json:"devices"` + } - 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") + } + // Filter only registered devices - var registeredDevices []device_connect.DeviceInfo - for _, device := range devices { - if device.IsRegistrable { + var registeredDevices []DeviceDTO + for _, device := range response.Devices { + if device.IsRegistered { registeredDevices = append(registeredDevices, device) } } @@ -137,12 +253,39 @@ func runInteractiveUnregisterSelection() error { fmt.Println() for i, device := range registeredDevices { - fmt.Printf("%d. %s (%s, %s) - %s\n", + // Get connectionType from metadata + var connectionType string + if device.Metadata != nil { + if ct, ok := device.Metadata["connectionType"].(string); ok { + connectionType = ct + } + } + + // Show Transport ID primarily for non-USB, else Serial No + deviceKey := device.TransportID + if strings.EqualFold(connectionType, "usb") && device.Serialno != "" { + deviceKey = device.Serialno + } + + // Get model from metadata + var model string + if device.Metadata != nil { + if m, ok := device.Metadata["model"].(string); ok { + model = m + } + } + if model == "" { + model = "Unknown" + } + if connectionType == "" { + connectionType = "unknown" + } + + fmt.Printf("%d. %s (%s, %s)\n", i+1, - device.Udid, - device.ProductModel, - device.ConnectionType, - device.ProductManufacturer) + deviceKey, + model, + connectionType) } fmt.Println() @@ -156,5 +299,10 @@ func runInteractiveUnregisterSelection() error { } selectedDevice := registeredDevices[choice-1] - return unregisterDevice(selectedDevice.Udid) + // Prefer transport id for API + deviceKey := selectedDevice.TransportID + if deviceKey == "" { + deviceKey = selectedDevice.Serialno + } + return unregisterDevice(deviceKey) } diff --git a/packages/cli/cmd/device_connect_utils.go b/packages/cli/cmd/device_connect_utils.go new file mode 100644 index 00000000..af3c0475 --- /dev/null +++ b/packages/cli/cmd/device_connect_utils.go @@ -0,0 +1,426 @@ +package cmd + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/fatih/color" +) + +// printDeveloperModeHint prints the developer mode hint with dim formatting +func printDeveloperModeHint() { + color.New(color.Faint).Println("If you can not see your devices here, make sure you have turned on the developer mode on your Android device. For more details, see https://docs.gbox.ai/cli") +} + +func checkAdbInstalled() bool { + _, err := exec.LookPath("adb") + return err == nil +} + +func checkFrpcInstalled() bool { + _, err := exec.LookPath("frpc") + return err == nil +} + +func printAdbInstallationHint() { + const ( + ansiRed = "\033[31m" + ansiYellow = "\033[33m" + ansiBold = "\033[1m" + ansiReset = "\033[0m" + ) + + fmt.Println() + fmt.Printf("%s%s⚠️ IMPORTANT: Android Debug Bridge (ADB) Required%s\n", ansiRed, ansiBold, ansiReset) + fmt.Printf("%s%s================================================%s\n", ansiYellow, ansiBold, ansiReset) + fmt.Printf("%sTo use the device-connect feature, you need to install ADB tools first:%s\n", ansiYellow, ansiReset) + fmt.Println() + fmt.Printf("%s📱 Installation Methods:%s\n", ansiBold, ansiReset) + fmt.Printf(" • macOS: brew install android-platform-tools\n") + fmt.Printf(" • Ubuntu/Debian: sudo apt-get install android-tools-adb\n") + fmt.Printf(" • Windows: Download Android SDK Platform Tools\n") + fmt.Println() + fmt.Printf("%s🔗 After installation, ensure:%s\n", ansiBold, ansiReset) + fmt.Printf(" 1. Enable Developer Options and USB Debugging on your Android device\n") + fmt.Printf(" 2. Connect device via USB or start an emulator\n") + fmt.Printf(" 3. Run 'adb devices' to confirm device recognition\n") + fmt.Println() + fmt.Printf("%s%s================================================%s\n", ansiYellow, ansiBold, ansiReset) + fmt.Println() +} + +func printFrpcInstallationHint() { + const ( + ansiRed = "\033[31m" + ansiYellow = "\033[33m" + ansiBold = "\033[1m" + ansiReset = "\033[0m" + ) + + fmt.Println() + fmt.Printf("%s%s⚠️ IMPORTANT: FRP Client (frpc) Required%s\n", ansiRed, ansiBold, ansiReset) + fmt.Printf("%s%s==============================================%s\n", ansiYellow, ansiBold, ansiReset) + fmt.Printf("%sTo use the device-connect feature, you need to install frpc (FRP Client) first:%s\n", ansiYellow, ansiReset) + fmt.Println() + fmt.Printf("%s🌐 Installation Methods:%s\n", ansiBold, ansiReset) + fmt.Printf(" • macOS: brew install frpc\n") + fmt.Printf(" • Ubuntu/Debian: Download from https://github.com/fatedier/frp/releases\n") + fmt.Printf(" • Windows: Download from https://github.com/fatedier/frp/releases\n") + fmt.Println() + fmt.Printf("%s📥 Manual Installation:%s\n", ansiBold, ansiReset) + fmt.Printf(" 1. Download frpc binary for your platform from GitHub releases\n") + fmt.Printf(" 2. Extract and place frpc in your PATH or current directory\n") + fmt.Printf(" 3. Ensure frpc is executable: chmod +x frpc\n") + fmt.Println() + fmt.Printf("%s🔗 After installation, ensure:%s\n", ansiBold, ansiReset) + fmt.Printf(" 1. frpc is in your PATH or current directory\n") + fmt.Printf(" 2. Run 'frpc version' to confirm installation\n") + fmt.Println() + fmt.Printf("%s%s==============================================%s\n", ansiYellow, ansiBold, ansiReset) + fmt.Println() +} + +// getMacOSSerialNumber gets macOS serial number using system_profiler +func getMacOSSerialNumber() (string, error) { + cmd := exec.Command("system_profiler", "SPHardwareDataType") + output, err := cmd.Output() + if err != nil { + return "", err + } + + // Parse output to find Serial Number + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "Serial Number") { + // Extract serial number from line like " Serial Number (system): H96D2Q2LR3" + parts := strings.Split(line, ":") + if len(parts) >= 2 { + serialNo := strings.TrimSpace(parts[1]) + if serialNo != "" { + return serialNo, nil + } + } + } + } + return "", fmt.Errorf("serial number not found") +} + +// getMacOSVersion gets macOS version and returns release name with version (e.g., "Sequoia 15.6.1") +func getMacOSVersion() (string, error) { + cmd := exec.Command("sw_vers", "-productVersion") + output, err := cmd.Output() + if err != nil { + return "", err + } + version := strings.TrimSpace(string(output)) + + // Get release name from system file + releaseName := getMacOSReleaseName() + if releaseName != "" { + return fmt.Sprintf("%s %s", releaseName, version), nil + } + return version, nil +} + +// getMacOSReleaseName gets macOS release name from system file +func getMacOSReleaseName() string { + // Use awk to extract release name from system license file + // Command: awk '/SOFTWARE LICENSE AGREEMENT FOR macOS/' '/System/Library/CoreServices/Setup Assistant.app/Contents/Resources/en.lproj/OSXSoftwareLicense.rtf' | awk -F 'macOS ' '{print $NF}' | awk '{print substr($0, 0, length($0)-1)}' + licenseFile := "/System/Library/CoreServices/Setup Assistant.app/Contents/Resources/en.lproj/OSXSoftwareLicense.rtf" + + // Check if file exists + if _, err := os.Stat(licenseFile); os.IsNotExist(err) { + return "" + } + + // Run the awk command to extract release name + cmd := exec.Command("sh", "-c", fmt.Sprintf("awk '/SOFTWARE LICENSE AGREEMENT FOR macOS/' '%s' | awk -F 'macOS ' '{print $NF}' | awk '{print substr($0, 0, length($0)-1)}'", licenseFile)) + output, err := cmd.Output() + if err != nil { + return "" + } + + releaseName := strings.TrimSpace(string(output)) + if releaseName != "" { + return releaseName + } + return "" +} + +// getLinuxVersion gets Linux distribution version +func getLinuxVersion() (string, error) { + // Try /etc/os-release first + if _, err := os.Stat("/etc/os-release"); err == nil { + cmd := exec.Command("sh", "-c", "source /etc/os-release && echo $PRETTY_NAME") + output, err := cmd.Output() + if err == nil { + version := strings.TrimSpace(string(output)) + if version != "" { + return version, nil + } + } + } + return "Linux", nil +} + +// getWindowsVersion gets Windows version +func getWindowsVersion() (string, error) { + cmd := exec.Command("cmd", "/c", "ver") + output, err := cmd.Output() + if err != nil { + return "", err + } + version := strings.TrimSpace(string(output)) + // Clean up output (remove "Microsoft Windows [" prefix and "]" suffix if present) + version = strings.TrimPrefix(version, "Microsoft Windows [") + version = strings.TrimSuffix(version, "]") + return strings.TrimSpace(version), nil +} + +// runAsRoot executes a command with root privileges if needed +func runAsRoot(name string, args ...string) error { + // Check if already running as root (Unix-like systems) + if runtime.GOOS != "windows" { + cmd := exec.Command("id", "-u") + output, err := cmd.Output() + if err == nil && strings.TrimSpace(string(output)) == "0" { + // Already root, run directly + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + } + + // Check if sudo is available + if _, err := exec.LookPath("sudo"); err == nil { + // Use sudo + fullArgs := append([]string{name}, args...) + cmd := exec.Command("sudo", fullArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + // No sudo available, try running directly + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// installADB attempts to install ADB using the system package manager +func installADB() error { + if _, err := exec.LookPath("brew"); err == nil { + // macOS with Homebrew + cmd := exec.Command("brew", "install", "android-platform-tools") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + if _, err := exec.LookPath("apt-get"); err == nil { + // Debian/Ubuntu + return runAsRoot("apt-get", "install", "-y", "android-tools-adb") + } + + if _, err := exec.LookPath("yum"); err == nil { + // RHEL/CentOS + return runAsRoot("yum", "install", "-y", "android-tools") + } + + return fmt.Errorf("unable to detect package manager") +} + +// installFrpc attempts to install frpc using the system package manager or GitHub releases +func installFrpc() error { + // Try Homebrew first on macOS + if runtime.GOOS == "darwin" { + if _, err := exec.LookPath("brew"); err == nil { + cmd := exec.Command("brew", "install", "frpc") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + return nil + } + // If brew fails, fall through to GitHub installation + } + } + + // Download from GitHub releases for all platforms + return installFrpcFromGitHub() +} + +// installFrpcFromGitHub downloads and installs frpc from GitHub releases +func installFrpcFromGitHub() error { + // Get latest frpc version from GitHub API + resp, err := http.Get("https://api.github.com/repos/fatedier/frp/releases/latest") + if err != nil { + return fmt.Errorf("failed to fetch frpc version: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to fetch frpc version: HTTP %d", resp.StatusCode) + } + + var release struct { + TagName string `json:"tag_name"` + } + + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return fmt.Errorf("failed to parse release info: %v", err) + } + + // Remove 'v' prefix from version + frpcVersion := strings.TrimPrefix(release.TagName, "v") + if frpcVersion == "" { + return fmt.Errorf("invalid version tag: %s", release.TagName) + } + + // Detect OS and architecture + osType := runtime.GOOS + archType := runtime.GOARCH + + // Map architecture names to frp naming convention + switch archType { + case "amd64": + // Keep as is + case "arm64": + // Keep as is + case "arm": + // Keep as is + default: + return fmt.Errorf("unsupported architecture: %s", archType) + } + + // Construct download URL + downloadURL := fmt.Sprintf( + "https://github.com/fatedier/frp/releases/download/v%s/frp_%s_%s_%s.tar.gz", + frpcVersion, frpcVersion, osType, archType, + ) + + // Create temporary directory for frpc binary only + tempDir, err := os.MkdirTemp("", "frpc-install-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + // Download and stream-extract in one pass + resp, err = http.Get(downloadURL) + if err != nil { + return fmt.Errorf("failed to download frpc: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download frpc: HTTP %d", resp.StatusCode) + } + + // Create gzip reader directly from HTTP response body (no intermediate file) + gzr, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %v", err) + } + defer gzr.Close() + + // Create tar reader from gzip stream + tr := tar.NewReader(gzr) + + // Extract frpc binary directly from stream + var frpcBinaryPath string + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar entry: %v", err) + } + + // Look for frpc binary + if filepath.Base(header.Name) == "frpc" && header.Typeflag == tar.TypeReg { + frpcBinaryPath = filepath.Join(tempDir, "frpc") + + // Create file with proper permissions + outFile, err := os.OpenFile(frpcBinaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return fmt.Errorf("failed to create frpc binary: %v", err) + } + + // Stream-copy from tar to file + if _, err := io.Copy(outFile, tr); err != nil { + outFile.Close() + return fmt.Errorf("failed to extract frpc binary: %v", err) + } + + if err := outFile.Close(); err != nil { + return fmt.Errorf("failed to close frpc binary: %v", err) + } + + // Found and extracted, stop processing archive + break + } + } + + if frpcBinaryPath == "" { + return fmt.Errorf("frpc binary not found in archive") + } + + // Install to system location + installPath := "/usr/local/bin/frpc" + if err := installBinaryWithSudo(frpcBinaryPath, installPath); err != nil { + return fmt.Errorf("failed to install frpc to %s: %v", installPath, err) + } + + return nil +} + +// installBinaryWithSudo installs a binary to the system location, using sudo if necessary +func installBinaryWithSudo(src, dst string) error { + // Try direct copy first (works if we have write permission) + if err := copyBinaryFile(src, dst); err == nil { + return nil + } + + // If direct copy fails, use install command with runAsRoot (Unix-like systems) + if runtime.GOOS != "windows" { + if err := runAsRoot("install", "-m", "755", src, dst); err != nil { + return fmt.Errorf("install with elevated privileges failed: %v", err) + } + return nil + } + + return fmt.Errorf("permission denied and elevated privileges not available") +} + +// copyBinaryFile copies a binary file with executable permissions +func copyBinaryFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open source: %v", err) + } + defer srcFile.Close() + + dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return fmt.Errorf("failed to create destination: %v", err) + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return fmt.Errorf("failed to copy: %v", err) + } + + return nil +} diff --git a/packages/cli/cmd/render.go b/packages/cli/cmd/render.go deleted file mode 100644 index 6ab4de8f..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/cmd/root.go b/packages/cli/cmd/root.go index df2953ed..b69b67c0 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" ) @@ -17,10 +18,19 @@ var ( 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 { @@ -71,6 +87,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..3bb01717 --- /dev/null +++ b/packages/cli/cmd/server.go @@ -0,0 +1,390 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "syscall" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/daemon" + procgroup "github.com/babelcloud/gbox/packages/cli/internal/proc_group" + "github.com/babelcloud/gbox/packages/cli/internal/server" + "github.com/google/uuid" + "github.com/pkg/errors" + "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 + internalDaemon bool + daemonStartLogFilename string + ) + + cmd := &cobra.Command{ + Use: "start", + Short: "Start the server", + Long: `Start the gbox server if it's not already running.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + if foreground { + // Run in foreground mode + return runServerInForeground(port) + } + if internalDaemon { + return runServerInBackground(port, daemonStartLogFilename) + } + // Default: run in daemon mode with IPC communication + return runServerInDaemon(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)") + + // Flag --internal-daemon is hidden in help message for internal use. + flags.BoolVarP(&internalDaemon, "internal-daemon", "", false, "") + flags.Lookup("internal-daemon").Hidden = true + flags.StringVarP(&daemonStartLogFilename, "daemon-start-log-filename", "", "", "") + flags.Lookup("daemon-start-log-filename").Hidden = true + + return cmd +} + +// newServerStopCmd creates the 'server stop' subcommand +func newServerStopCmd() *cobra.Command { + var ( + port int + force bool + ) + + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop the server", + Long: `Stop the gbox server if it's running.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return stopServer(port, force) + }, + Example: ` # Stop the server + gbox server stop + + # Stop server running on specified port + gbox server stop --port 29888 + gbox server stop -p 29888`, + } + + flags := cmd.Flags() + flags.IntVarP(&port, "port", "p", 29888, "Server port") + 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.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := stopServer(port, true); err != nil { + return err + } + if foreground { + // Run in foreground mode + return runServerInForeground(port) + } + return runServerInDaemon(port) + }, + 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 + +// runServerInDaemon runs the server in daemon mode with IPC communication +func runServerInDaemon(port int) error { + if err := checkServerStatus(port); err != nil { + if err == ServerMismatchedError { + return errors.Wrapf(err, "port %d is already been used", port) + } + } else { + fmt.Printf("server has been already started on port %d\n", port) + return nil + } + + executable, err := os.Executable() + if err != nil { + return errors.Wrap(err, "failed to get exectuable") + } + + runId := uuid.New() + daemonStartLogFilename := filepath.Join(os.TempDir(), "gbox-server-"+runId.String()) + defer os.RemoveAll(daemonStartLogFilename) + + cmd := exec.Command(executable, "server", "start", "--port", strconv.Itoa(port), "--internal-daemon", "--daemon-start-log-filename", daemonStartLogFilename) + procgroup.SetProcGrp(cmd) + if err := cmd.Start(); err != nil { + return errors.Wrap(err, "failed to start server daemon") + } + + for range 3 { + time.Sleep(time.Second) + if err := checkServerStatus(port); err != nil { + startLog, err := os.ReadFile(daemonStartLogFilename) + if err != nil { + continue + } + return errors.Errorf("fail to start server on port %d: %s", port, string(startLog)) + } + } + + fmt.Printf("server has been started on port %d\n", port) + return nil +} + +func runServerInBackground(port int, startLogFilename string) error { + userHome, err := os.UserHomeDir() + if err != nil { + err := errors.Wrapf(err, "failed to get user home directory") + os.WriteFile(startLogFilename, []byte(err.Error()), 0600) + return err + } + + logFile := filepath.Join(userHome, ".gbox/cli/server.log") + logFd, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + err := errors.Wrapf(err, "failed to create log file: %s", logFile) + os.WriteFile(startLogFilename, []byte(err.Error()), 0600) + return err + } + defer logFd.Close() + + log.SetOutput(logFd) + log.SetFlags(log.LstdFlags) + + server := server.NewGBoxServer(port) + if err := server.Start(); err != nil && err != http.ErrServerClosed { + err := errors.Wrapf(err, "failed to start server") + os.WriteFile(startLogFilename, []byte(err.Error()), 0600) + return err + } + return nil +} + +func checkServerStatus(port int) error { + url := fmt.Sprintf("http://localhost:%d/api/health", port) + resp, err := http.Get(url) + if err != nil { + return ServerPortUnavailableError + } + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + var body struct { + Status string `json:"status"` + Service string `json:"service"` + } + if err := decoder.Decode(&body); err != nil { + return ServerMismatchedError + } + if body.Service != "gbox-server" { + return ServerMismatchedError + } + return nil +} + +func stopServer(port int, force bool) error { + if err := checkServerStatus(port); err != nil && !force { + if err == ServerPortUnavailableError { + return errors.Errorf("server is not running") + } + if err == ServerMismatchedError { + return errors.Wrapf(err, "port %d is already been used by other process", port) + } + } + + url := fmt.Sprintf("http://localhost:%d/api/server/shutdown", port) + resp, err := http.Post(url, "application/json", nil) + if err != nil { + if force { + return nil + } + return ServerPortUnavailableError + } + defer resp.Body.Close() + io.ReadAll(resp.Body) + time.Sleep(1 * time.Second) + return nil +} + +func runServerInForeground(port int) error { + if err := checkServerStatus(port); err != nil { + if err == ServerMismatchedError { + return errors.Wrapf(err, "port %d is already been used", port) + } + } else { + fmt.Printf("server has been already started on port %d\n", port) + return nil + } + + server := server.NewGBoxServer(port) + errChan := make(chan error) + go func() { + if err := server.Start(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + }() + + for range 3 { + time.Sleep(time.Second) + if err := checkServerStatus(port); err != nil { + select { + case startErr := <-errChan: + return errors.Wrapf(startErr, "fail to start server on port %d", port) + default: + continue + } + } + } + + // 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) + 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 +} + +var ServerPortUnavailableError = &serverPortUnavailableError{} + +type serverPortUnavailableError struct{} + +func (e *serverPortUnavailableError) Error() string { + return "server port unavailable" +} + +var ServerMismatchedError = &serverMismatchedError{} + +type serverMismatchedError struct{} + +func (e *serverMismatchedError) Error() string { + return "server mismatched" +} diff --git a/packages/cli/go.mod b/packages/cli/go.mod index 3c444379..72a62bf9 100644 --- a/packages/cli/go.mod +++ b/packages/cli/go.mod @@ -1,23 +1,34 @@ module github.com/babelcloud/gbox/packages/cli -go 1.23.7 +go 1.25 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/basiooo/goadb v1.1.1 + github.com/bluenviron/mediacommon/v2 v2.4.3 + github.com/dchest/uniuri v1.2.0 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/pion/webrtc/v4 v4.1.4 + github.com/pires/go-proxyproto v0.8.1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 + github.com/vishalkuo/bimap v0.0.0-20230830142743-a9fb9b52066c + github.com/xtaci/smux v1.5.35 golang.org/x/oauth2 v0.27.0 golang.org/x/term v0.31.0 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 ) 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 @@ -29,15 +40,36 @@ require ( ) require ( + github.com/abema/go-mp4 v1.4.1 // indirect + github.com/asticode/go-astikit v0.30.0 // indirect + 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/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.37.0 // indirect + golang.org/x/net v0.39.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 fab3b061..5d8ff536 100644 --- a/packages/cli/go.sum +++ b/packages/cli/go.sum @@ -1,16 +1,29 @@ +github.com/abema/go-mp4 v1.4.1 h1:YoS4VRqd+pAmddRPLFf8vMk74kuGl6ULSjzhsIqwr6M= +github.com/abema/go-mp4 v1.4.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= +github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= +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/basiooo/goadb v1.1.1 h1:gh8EqRCdmrkaBPiW3YvgEKXpmMkU0VNhpqz7WKMAzGg= +github.com/basiooo/goadb v1.1.1/go.mod h1:pp54HJu7sBRbN2ImV1U9FikU4qh8AJ/kYNF+47+cBSQ= +github.com/bluenviron/mediacommon/v2 v2.4.3 h1:GFXKaMFgnQqbKv+uaAEfmgBZXBBRTtwabVepDowVDtM= +github.com/bluenviron/mediacommon/v2 v2.4.3/go.mod h1:zy1fODPuS/kBd93ftgJS1Jhvjq7LFWfAo32KP7By9AE= github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= +github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -21,14 +34,19 @@ 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.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= @@ -37,10 +55,49 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= +github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= 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/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= 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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -64,11 +121,13 @@ github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -79,25 +138,45 @@ 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/vishalkuo/bimap v0.0.0-20230830142743-a9fb9b52066c h1:KEcyvVSSrD/dq45OZpwFG7/gi608xzyriB8kgquCgPU= +github.com/vishalkuo/bimap v0.0.0-20230830142743-a9fb9b52066c/go.mod h1:oVZgsxuZOVCkAVcgSitAfucCKX0Ani+R8bIbfGdqAkg= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xtaci/smux v1.5.35 h1:RosihGJBeaS8gxOZ17HNxbhONwnqQwNwusHx4+SEGhk= +github.com/xtaci/smux v1.5.35/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY= 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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 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-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 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..9a92afe9 --- /dev/null +++ b/packages/cli/internal/adb_expose/commands.go @@ -0,0 +1,614 @@ +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" + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// 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 + } + + // Prepare data for RenderTable + tableData := make([]map[string]interface{}, len(data)) + for i, row := range data { + boxID, _ := row["box_id"].(string) + port, _ := row["port"].(string) + startedAt, _ := row["started_at"].(string) + + tableData[i] = map[string]interface{}{ + "box_id": boxID, + "port": port, + "started_at": startedAt, + } + } + + // Define table columns + columns := []util.TableColumn{ + {Header: "Box ID", Key: "box_id"}, + {Header: "Port", Key: "port"}, + {Header: "Started At", Key: "started_at"}, + } + + util.RenderTable(columns, tableData) +} + +// 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 +} 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/cloud/ap.go b/packages/cli/internal/cloud/ap.go new file mode 100644 index 00000000..cad9edfc --- /dev/null +++ b/packages/cli/internal/cloud/ap.go @@ -0,0 +1,114 @@ +package cloud + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/babelcloud/gbox/packages/cli/internal/profile" + "github.com/pkg/errors" +) + +type AccessPoint struct { + Id string `json:"id"` + Endpoint string `json:"endpoint"` + Metadata struct { + Country string `json:"country"` + Region string `json:"region"` + City string `json:"city"` + Latitude string `json:"latitude"` + Longitude string `json:"longitude"` + Timezone string `json:"timezone"` + Protocol string `json:"protocol"` + } `json:"metadata"` +} + +type AccessPointList struct { + Data []*AccessPoint `json:"data"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` +} + +type AccessPointAPI struct { + client *http.Client +} + +func NewAccessPointAPI() *AccessPointAPI { + return &AccessPointAPI{ + client: &http.Client{}, + } +} + +// getCurrentProfile gets the current profile dynamically to support profile switching +func (ap *AccessPointAPI) getCurrentProfile() *profile.Profile { + return profile.Default.GetCurrent() +} + +func (ap *AccessPointAPI) List() (*AccessPointList, error) { + url, err := ap.buildUrlFromEndpoint("/api/v1/access-points") + if err != nil { + return nil, errors.Wrap(err, "failed to build url") + } + + req, err := http.NewRequest(http.MethodGet, url.String(), nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to create request from url: %s", url.String()) + } + + ap.setCommonRequestHeaders(req) + + resp, err := ap.client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "failed to get access points: %s", url.String()) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, errors.Errorf("get access points api respond %d: %s", resp.StatusCode, string(body)) + } + + decoder := json.NewDecoder(resp.Body) + apList := &AccessPointList{} + if err := decoder.Decode(apList); err != nil { + return nil, errors.Wrapf(err, "failed to parse response from get access points api") + } + + return apList, nil +} + +func (ap *AccessPointAPI) buildUrlFromEndpoint(endpoint string) (*url.URL, error) { + currentProfile := ap.getCurrentProfile() + if currentProfile == nil { + return nil, errors.New("no current profile set") + } + + url, err := url.Parse(currentProfile.BaseURL) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse base url: %s", currentProfile.BaseURL) + } + + url.Path = endpoint + + return url, nil +} + +func (ap *AccessPointAPI) setCommonRequestHeaders(req *http.Request) { + currentProfile := ap.getCurrentProfile() + if currentProfile == nil { + return + } + + req.Header.Set("content-type", "application/json") + decodedBytes, _ := base64.StdEncoding.DecodeString(currentProfile.APIKey) + apiKey := string(decodedBytes) + if strings.HasPrefix(apiKey, "gbox-rack_") { + req.Header.Set("x-rack-api-key", apiKey) + } else { + req.Header.Set("x-api-key", apiKey) + } +} diff --git a/packages/cli/internal/cloud/device.go b/packages/cli/internal/cloud/device.go new file mode 100644 index 00000000..208a6b26 --- /dev/null +++ b/packages/cli/internal/cloud/device.go @@ -0,0 +1,350 @@ +package cloud + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/profile" + "github.com/pkg/errors" +) + +type Device struct { + Id string `json:"id,omitempty"` + RegId string `json:"regId,omitempty"` + Ownership string `json:"ownership,omitempty"` + OwnerId string `json:"ownerId,omitempty"` + Metadata struct { + Serialno string `json:"serialno,omitempty"` + AndroidId string `json:"androidId,omitempty"` + Type string `json:"type,omitempty"` // Deprecated: use DeviceType and OsType instead + DeviceType string `json:"deviceType,omitempty"` // mobile, desktop + OsType string `json:"osType,omitempty"` // android, linux, windows, macos + Resolution string `json:"resolution,omitempty"` + Hostname string `json:"hostname,omitempty"` // Desktop device hostname + Chip string `json:"chip,omitempty"` // macOS chip information + OsVersion string `json:"osVersion,omitempty"` // OS version + Memory string `json:"memory,omitempty"` // Memory size + Model string `json:"model,omitempty"` // Android device model + Manufacturer string `json:"manufacturer,omitempty"` // Android device manufacturer + ConnectionType string `json:"connectionType,omitempty"` // Android connection type (usb, tcp, etc.) + } `json:"metadata,omitzero"` + Labels map[string]string `json:"labels,omitempty"` + AccessPointId string `json:"accessPointId,omitempty"` + Connected bool `json:"connected,omitempty"` + Available bool `json:"available,omitempty"` + LastOnlineAt time.Time `json:"lastOnlineAt,omitzero"` +} + +type DeviceList struct { + Data []*Device `json:"data"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` +} + +type Box struct { + Id string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` +} + +type AccessPointToken struct { + Token string `json:"token"` +} + +type DeviceAPI struct { + client *http.Client +} + +func NewDeviceAPI() *DeviceAPI { + return &DeviceAPI{ + client: &http.Client{}, + } +} + +// getCurrentProfile gets the current profile dynamically to support profile switching +func (d *DeviceAPI) getCurrentProfile() *profile.Profile { + return profile.Default.GetCurrent() +} + +// getDevices is a generic method to query devices with query parameters +func (d *DeviceAPI) getDevices(queries url.Values) (*DeviceList, error) { + url, err := d.buildUrlFromEndpoint("/api/v1/devices") + if err != nil { + return nil, errors.Wrap(err, "failed to build url") + } + + url.RawQuery = queries.Encode() + + req, err := http.NewRequest(http.MethodGet, url.String(), nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to create request from url: %s", url.String()) + } + + d.setCommonRequestHeaders(req) + + resp, err := d.client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "failed to get devices: %s", url.String()) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, errors.Errorf("get devices api respond %d: %s", resp.StatusCode, string(body)) + } + + decoder := json.NewDecoder(resp.Body) + deviceList := &DeviceList{} + if err := decoder.Decode(deviceList); err != nil { + return nil, errors.Wrapf(err, "failed to parse response from get devices api") + } + + return deviceList, nil +} + +func (d *DeviceAPI) GetBySerialnoAndAndroidId(serialno string, androidId string) (*DeviceList, error) { + queries := url.Values{} + queries.Set("serialno", serialno) + queries.Set("androidId", androidId) + return d.getDevices(queries) +} + +func (d *DeviceAPI) GetByRegId(regId string) (*DeviceList, error) { + queries := url.Values{} + queries.Set("regId", regId) + return d.getDevices(queries) +} + +// GetAll gets all devices from the cloud +func (d *DeviceAPI) GetAll() (*DeviceList, error) { + return d.getDevices(url.Values{}) +} + +func (d *DeviceAPI) Create(device *Device) (*Device, error) { + url, err := d.buildUrlFromEndpoint("/api/v1/devices") + + if err != nil { + return nil, errors.Wrapf(err, "failed to build url") + } + + reqBody, err := json.Marshal(device) + if err != nil { + return nil, errors.Wrap(err, "fail to marshal device to json") + } + + req, err := http.NewRequest(http.MethodPost, url.String(), bytes.NewReader(reqBody)) + if err != nil { + return nil, errors.Wrapf(err, "failed to create request from url: %s", url.String()) + } + + d.setCommonRequestHeaders(req) + + resp, err := d.client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "failed to post devices: %s", url.String()) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return nil, errors.Errorf("post devices api respond %d: %s", resp.StatusCode, string(body)) + } + + decoder := json.NewDecoder(resp.Body) + device = &Device{} + if err := decoder.Decode(device); err != nil { + return nil, errors.Wrapf(err, "failed to parse response from post devices api") + } + + return device, nil +} + +func (d *DeviceAPI) List(page, pageSize int) (*DeviceList, error) { + u, err := d.buildUrlFromEndpoint("/api/v1/devices") + if err != nil { + return nil, errors.Wrap(err, "failed to build url") + } + q := u.Query() + if page > 0 { + q.Set("page", fmt.Sprintf("%d", page)) + } + if pageSize > 0 { + q.Set("pageSize", fmt.Sprintf("%d", pageSize)) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to create request from url: %s", u.String()) + } + + d.setCommonRequestHeaders(req) + + resp, err := d.client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "failed to get devices: %s", u.String()) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, errors.Errorf("get devices api respond %d: %s", resp.StatusCode, string(body)) + } + + decoder := json.NewDecoder(resp.Body) + list := &DeviceList{} + if err := decoder.Decode(list); err != nil { + return nil, errors.Wrapf(err, "failed to parse response from get devices api") + } + return list, nil +} + +func (d *DeviceAPI) Delete(deviceId string) error { + url, err := d.buildUrlFromEndpoint(path.Join("/api/v1/devices", deviceId)) + if err != nil { + return errors.Wrapf(err, "failed to build url") + } + + req, err := http.NewRequest(http.MethodDelete, url.String(), nil) + if err != nil { + return errors.Wrapf(err, "failed to create request from url: %s", url.String()) + } + + d.setCommonRequestHeaders(req) + + resp, err := d.client.Do(req) + if err != nil { + return errors.Wrapf(err, "failed to delete devices: %s", url.String()) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusNoContent { + return errors.Errorf("delete devices api respond %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +func (d *DeviceAPI) GenerateAccessPointToken(deviceId, requestEndpoint string) (*AccessPointToken, error) { + url, err := d.buildUrlFromEndpoint(path.Join("/api/v1/devices", deviceId, "generate-access-point-token")) + if err != nil { + return nil, errors.Wrapf(err, "failed to build url") + } + + reqBody, err := json.Marshal(map[string]any{ + "requestEndpoint": requestEndpoint, + }) + if err != nil { + return nil, errors.Wrap(err, "fail to marshal generate access point request body to json") + } + + req, err := http.NewRequest(http.MethodPost, url.String(), bytes.NewReader(reqBody)) + if err != nil { + return nil, errors.Wrapf(err, "failed to create request from url: %s", url.String()) + } + + d.setCommonRequestHeaders(req) + + resp, err := d.client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "failed to generate access point token: %s", url.String()) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, errors.Errorf("generate access point token api respond %d: %s", resp.StatusCode, string(body)) + } + + decoder := json.NewDecoder(resp.Body) + apToken := &AccessPointToken{} + if err := decoder.Decode(apToken); err != nil { + return nil, errors.Wrapf(err, "failed to parse response from generate access point token api") + } + + return apToken, nil +} + +func (d *DeviceAPI) buildUrlFromEndpoint(endpoint string) (*url.URL, error) { + currentProfile := d.getCurrentProfile() + if currentProfile == nil { + return nil, errors.New("no current profile set") + } + + url, err := url.Parse(currentProfile.BaseURL) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse base url: %s", currentProfile.BaseURL) + } + + url.Path = endpoint + + return url, nil +} + +func (d *DeviceAPI) DeviceToBox(deviceId string, force bool) (*Box, error) { + url, err := d.buildUrlFromEndpoint(path.Join("/api/v1/devices", deviceId, "box")) + if err != nil { + return nil, errors.Wrapf(err, "failed to build url") + } + + reqBody, err := json.Marshal(map[string]interface{}{ + "force": force, + }) + if err != nil { + return nil, errors.Wrap(err, "fail to marshal device to box request body to json") + } + + req, err := http.NewRequest(http.MethodPost, url.String(), bytes.NewReader(reqBody)) + if err != nil { + return nil, errors.Wrapf(err, "failed to create request from url: %s", url.String()) + } + + d.setCommonRequestHeaders(req) + + resp, err := d.client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "failed to post device to box: %s", url.String()) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return nil, errors.Errorf("post device to box api respond %d: %s", resp.StatusCode, string(body)) + } + + decoder := json.NewDecoder(resp.Body) + box := &Box{} + if err := decoder.Decode(box); err != nil { + return nil, errors.Wrapf(err, "failed to parse response from post device to box api") + } + + return box, nil +} + +func (d *DeviceAPI) setCommonRequestHeaders(req *http.Request) { + currentProfile := d.getCurrentProfile() + if currentProfile == nil { + return + } + + req.Header.Set("x-device-ap", "true") + req.Header.Set("content-type", "application/json") + decodedBytes, _ := base64.StdEncoding.DecodeString(currentProfile.APIKey) + apiKey := string(decodedBytes) + if strings.HasPrefix(apiKey, "gbox-rack_") { + req.Header.Set("x-rack-api-key", apiKey) + } else { + req.Header.Set("x-api-key", apiKey) + } +} diff --git a/packages/cli/internal/daemon/manager.go b/packages/cli/internal/daemon/manager.go new file mode 100644 index 00000000..9cc37fbe --- /dev/null +++ b/packages/cli/internal/daemon/manager.go @@ -0,0 +1,320 @@ +package daemon + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/server" + "github.com/babelcloud/gbox/packages/cli/internal/version" +) + +const ( + DefaultPort = 29888 // New port for unified gbox server + ServerURL = "http://localhost:29888" + + // Version headers returned by server + serverHeaderVersion = "X-GBOX-Version" + serverHeaderBuildID = "X-GBOX-Build-ID" +) + +// 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) + // Avoid a second health check log; treat as not running + return false + } + + // 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: 150 * time.Millisecond} + resp, err := client.Get(fmt.Sprintf("%s/api/server/info", m.url)) + if err != nil { + return false + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false + } + serverVersion := resp.Header.Get(serverHeaderVersion) + serverBuildID := resp.Header.Get(serverHeaderBuildID) + clientVersion := version.Version + clientBuildID := server.GetBuildID() + if (serverVersion != "" && clientVersion != "" && serverVersion != clientVersion) || + (serverBuildID != "" && clientBuildID != "" && serverBuildID != clientBuildID) { + log.Printf("Detected binary change: server(version=%s, build=%s) != client(version=%s, build=%s). Will restart server.", serverVersion, serverBuildID, clientVersion, clientBuildID) + return false + } + return true +} + +// 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", "start", "--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} + + // perform is a small helper to execute the request + perform := func() (*http.Response, error) { + return client.Do(req) + } + + resp, err := perform() + if err != nil { + return fmt.Errorf("API call failed: %v", err) + } + defer resp.Body.Close() + + // Version/build-id check: compare headers with client values. + serverVersion := resp.Header.Get(serverHeaderVersion) + serverBuildID := resp.Header.Get(serverHeaderBuildID) + clientVersion := version.Version + clientBuildID := server.GetBuildID() + + mismatch := false + if serverVersion != "" && clientVersion != "" && serverVersion != clientVersion { + mismatch = true + } + if serverBuildID != "" && clientBuildID != "" && serverBuildID != clientBuildID { + mismatch = true + } + + if mismatch { + _ = m.StopServer() + if err := m.StartServer(); err != nil { + return fmt.Errorf("server mismatch (ver:%s!=%s or build:%s!=%s) and restart failed: %v", serverVersion, clientVersion, serverBuildID, clientBuildID, err) + } + // Retry once with a fresh request object (req body may have been consumed) + if body != nil { + jsonData, _ := json.Marshal(body) + req, err = http.NewRequest(method, url, bytes.NewReader(jsonData)) + if err != nil { + return fmt.Errorf("failed to create retry request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + } else { + req, err = http.NewRequest(method, url, nil) + if err != nil { + return fmt.Errorf("failed to create retry request: %v", err) + } + } + resp, err = perform() + if err != nil { + return fmt.Errorf("API call after restart 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() 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/android.go b/packages/cli/internal/device/android.go new file mode 100644 index 00000000..48d6bc33 --- /dev/null +++ b/packages/cli/internal/device/android.go @@ -0,0 +1,368 @@ +package device + +import ( + "bytes" + "fmt" + "log" + "os/exec" + "strconv" + "strings" + + "github.com/pkg/errors" +) + +const ( + gboxRegIdSettingKey = "gbox_reg_id" + gboxDeviceIDFileDir = "/sdcard/.gbox" + gboxRegIdFilePath = "/sdcard/.gbox/reg_id" +) + +// AndroidManager manages Android devices (implements DeviceManager) +type AndroidManager struct { + adbPath string +} + +// GetDevices returns list of connected Android devices +func (m *AndroidManager) GetDevices() ([]DeviceInfo, 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") + var devices []DeviceInfo + + 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 { + continue + } + + deviceID := parts[0] + status := parts[1] + + // Only include devices with "device" status + if status != "device" { + continue + } + + device := DeviceInfo{ + ID: deviceID, + Status: status, + ConnectionType: "usb", // Default to USB connection + IsRegistrable: false, // Default to false, will be updated by caller if needed + } + + // Check if device is connected via network + if strings.Contains(deviceID, "._adb._tcp") { + // mDNS service name (e.g., "adb-A4RYVB3A20008848._adb._tcp") + device.ConnectionType = "mdns" + // Keep the full mDNS name as device ID + } else if strings.Contains(deviceID, ":") { + // IP address with port (e.g., "192.168.1.100:5555") + device.ConnectionType = "ip" + } + + // 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 { + // Map common fields to expected names + switch kv[0] { + case "model": + device.Model = kv[1] + case "device": + device.Manufacturer = kv[1] + } + } + } + } + } + + // Get serial number and Android ID + serialNo, err := m.getSerialNo(deviceID) + if err != nil { + log.Printf("Failed to get serialno of device %s: %v", deviceID, err) + // Use device ID as fallback for serial number + device.SerialNo = deviceID + device.AndroidID = "" + } else { + device.SerialNo = serialNo + androidID, err := m.getAndroidID(deviceID) + if err != nil { + log.Printf("Failed to get android id of device %s: %v", deviceID, err) + device.AndroidID = "" + } else { + device.AndroidID = androidID + } + } + + // Get reg_id for this device (non-fatal if fails) + regId, _ := m.GetRegId(deviceID) + device.RegId = regId + + devices = append(devices, device) + } + + return devices, nil +} + +// getSerialNo gets the device serial number +func (m *AndroidManager) getSerialNo(deviceID string) (string, error) { + cmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "getprop", "ro.serialno") + output, err := cmd.Output() + if err != nil { + return "", errors.Wrapf(err, "failed to get serialno of device %s", deviceID) + } + return strings.TrimSpace(string(output)), nil +} + +// getAndroidID gets the device Android ID +func (m *AndroidManager) getAndroidID(deviceID string) (string, error) { + cmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "settings", "get", "secure", "android_id") + output, err := cmd.Output() + if err != nil { + return "", errors.Wrapf(err, "failed to get android id of device %s", deviceID) + } + return strings.TrimSpace(string(output)), nil +} + +// GetIdentifiers returns device identifiers for the given device. +func (m *AndroidManager) GetIdentifiers(deviceID string) (Identifiers, error) { + serialNo, err := m.getSerialNo(deviceID) + if err != nil { + return Identifiers{}, err + } + + androidID, err := m.getAndroidID(deviceID) + if err != nil { + return Identifiers{}, err + } + + regId, _ := m.GetRegId(deviceID) // non-fatal + return Identifiers{ + SerialNo: serialNo, + AndroidID: &androidID, // Use pointer for Android devices + RegId: regId, + }, nil +} + +// SetRegId writes a registration ID to the device. +// It first tries to write into Android settings (global). If that fails (e.g., permission denied), +// it falls back to writing a file on external storage. +func (m *AndroidManager) SetRegId(deviceID string, regId string) error { + // Try settings put global first + putCmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "settings", "put", "global", gboxRegIdSettingKey, regId) + if err := putCmd.Run(); err == nil { + // Verify from settings + getCmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "settings", "get", "global", gboxRegIdSettingKey) + out, verr := getCmd.Output() + if verr == nil { + got := strings.TrimSpace(string(out)) + if got != "" && got != "null" && got == strings.TrimSpace(regId) { + // Enforce single source of truth: delete file; if deletion fails, report error + rmCmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "rm", "-f", gboxRegIdFilePath) + if err := rmCmd.Run(); err != nil { + return errors.Wrap(err, "failed to delete fallback reg_id file after successful settings write") + } + return nil + } + } + // if verification failed, fall through to file fallback + } + + // Fallback: write to file only (do not attempt settings again) + shell := fmt.Sprintf("mkdir -p %s && printf %s %s > %s", + gboxDeviceIDFileDir, "%s", shellQuoteForSingle(regId), gboxRegIdFilePath) + fileCmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "sh", "-c", shell) + if err := fileCmd.Run(); err != nil { + return errors.Wrap(err, "failed to write reg id to file") + } + + // Verify by reading the file + readCmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "cat", gboxRegIdFilePath) + out, err := readCmd.Output() + if err != nil { + return errors.Wrap(err, "failed to read back reg id from file") + } + got := strings.TrimSpace(string(out)) + if got != strings.TrimSpace(regId) { + return fmt.Errorf("verification failed (file): expected %q, got %q", regId, got) + } + return nil +} + +// GetRegId reads the registration ID from settings or fallback file. +func (m *AndroidManager) GetRegId(deviceID string) (string, error) { + // Prefer file first + readCmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "cat", gboxRegIdFilePath) + out, err := readCmd.Output() + if err == nil { + v := strings.TrimSpace(string(out)) + if v != "" { + return v, nil + } + } + + // Then try settings + getCmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "settings", "get", "global", gboxRegIdSettingKey) + out, err = getCmd.Output() + if err != nil { + return "", errors.Wrap(err, "failed to read reg id from settings") + } + v := strings.TrimSpace(string(out)) + if v == "null" { + v = "" + } + return v, nil +} + +// shellQuoteForSingle returns a single-quoted shell-safe string, handling embedded single quotes. +// e.g., abc'def -> 'abc'"'"'def' +func shellQuoteForSingle(s string) string { + if s == "" { + return "''" + } + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + +type AdbCommandResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exitCode"` +} + +func (m *AndroidManager) ExecAdbCommand(deviceID, command string) (*AdbCommandResult, error) { + cmd := exec.Command("sh", "-c", strings.Join([]string{m.adbPath, "-s", deviceID, command}, " ")) + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + if err := cmd.Run(); err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + return &AdbCommandResult{ + Stdout: stdoutBuf.String(), + Stderr: stderrBuf.String(), + ExitCode: exitError.ExitCode(), + }, nil + } + return nil, errors.Wrapf(err, "failed to exec adb command on device %s", deviceID) + } + + return &AdbCommandResult{ + Stdout: stdoutBuf.String(), + Stderr: stderrBuf.String(), + ExitCode: 0, + }, nil +} + +// GetDisplayResolution returns the device display resolution (width, height) in pixels. +// It prefers the "Override size" reported by `wm size` when present; otherwise it +// falls back to the "Physical size". +func (m *AndroidManager) GetDisplayResolution(deviceID string) (int, int, error) { + cmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "wm", "size") + output, err := cmd.Output() + if err != nil { + return 0, 0, errors.Wrapf(err, "failed to run wm size for device %s", deviceID) + } + + stdout := strings.TrimSpace(string(output)) + var sizeLine string + lines := strings.Split(stdout, "\n") + + // Prefer Override size; otherwise use Physical size + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "Override size:") { + sizeLine = strings.TrimSpace(strings.TrimPrefix(trimmed, "Override size:")) + break + } + if strings.HasPrefix(trimmed, "Physical size:") { + if sizeLine == "" { + sizeLine = strings.TrimSpace(strings.TrimPrefix(trimmed, "Physical size:")) + } + } + } + + if sizeLine == "" { + return 0, 0, fmt.Errorf("invalid screen size output: %s", stdout) + } + + parts := strings.Split(sizeLine, "x") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid screen size output: %s", stdout) + } + + width, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return 0, 0, fmt.Errorf("invalid screen size dimensions: %s", sizeLine) + } + height, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return 0, 0, fmt.Errorf("invalid screen size dimensions: %s", sizeLine) + } + + return width, height, nil +} + +// GetOSVersion returns the Android OS version (e.g., "14", "13") +func (m *AndroidManager) GetOSVersion(deviceID string) (string, error) { + // Try ro.build.version.release first (user-friendly version like "14", "13") + cmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "getprop", "ro.build.version.release") + output, err := cmd.Output() + if err == nil { + version := strings.TrimSpace(string(output)) + if version != "" { + return version, nil + } + } + + // Fallback to SDK version + cmd = exec.Command(m.adbPath, "-s", deviceID, "shell", "getprop", "ro.build.version.sdk") + output, err = cmd.Output() + if err == nil { + version := strings.TrimSpace(string(output)) + if version != "" { + return version, nil + } + } + + return "", fmt.Errorf("failed to get Android OS version") +} + +// GetMemory returns the total memory in GB (e.g., "8 GB") +func (m *AndroidManager) GetMemory(deviceID string) (string, error) { + // Read MemTotal from /proc/meminfo + cmd := exec.Command(m.adbPath, "-s", deviceID, "shell", "cat", "/proc/meminfo") + output, err := cmd.Output() + if err != nil { + return "", errors.Wrapf(err, "failed to read meminfo for device %s", deviceID) + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "MemTotal:") { + // Parse line like "MemTotal: 16384000 kB" + fields := strings.Fields(line) + if len(fields) >= 2 { + memKB, err := strconv.ParseInt(fields[1], 10, 64) + if err == nil { + // Convert KB to GB + memGB := float64(memKB) / (1024 * 1024) + return fmt.Sprintf("%.0f GB", memGB), nil + } + } + } + } + + return "", fmt.Errorf("failed to parse memory information") +} diff --git a/packages/cli/internal/device/desktop.go b/packages/cli/internal/device/desktop.go new file mode 100644 index 00000000..577224e9 --- /dev/null +++ b/packages/cli/internal/device/desktop.go @@ -0,0 +1,398 @@ +package device + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// DesktopManager manages desktop devices (implements DeviceManager) +type DesktopManager struct { + osType string +} + +// GetIdentifiers returns device identifiers for desktop devices +func (m *DesktopManager) GetIdentifiers(deviceID string) (Identifiers, error) { + // Desktop devices don't have Android ID + // Try to get regId from local file + regId, _ := m.GetRegId(deviceID) // non-fatal + return Identifiers{ + SerialNo: deviceID, // Use deviceID as serialno for desktop + AndroidID: nil, // Desktop devices don't have Android ID + RegId: regId, + }, nil +} + +// GetDisplayResolution returns the primary/built-in display resolution for desktop devices +func (m *DesktopManager) GetDisplayResolution(deviceID string) (int, int, error) { + switch m.osType { + case "macos": + return getMacOSDisplayResolution() + case "linux": + return getLinuxDisplayResolution() + case "windows": + return getWindowsDisplayResolution() + default: + return 0, 0, fmt.Errorf("unsupported OS type: %s", m.osType) + } +} + +// getMacOSDisplayResolution gets the primary display resolution on macOS +func getMacOSDisplayResolution() (int, int, error) { + // Use system_profiler to get display info + // Priority: 1. Built-in display, 2. Main Display, 3. First display + cmd := exec.Command("system_profiler", "SPDisplaysDataType") + output, err := cmd.Output() + if err != nil { + return 0, 0, err + } + + lines := strings.Split(string(output), "\n") + var builtInResolution string + var mainDisplayResolution string + var firstResolution string + + // Track current display context + type displayContext struct { + isBuiltIn bool + isMainDisplay bool + resolution string + } + + var currentDisplay displayContext + displays := []displayContext{} + + // Parse output line by line + // Display sections start with "Displays:" and each display name is indented with spaces/tabs + inDisplaysSection := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Detect when we enter the Displays section + if strings.Contains(trimmed, "Displays:") { + inDisplaysSection = true + continue + } + + // Only process lines within Displays section + if !inDisplaysSection { + continue + } + + // Detect new display section (indented line ending with colon, like " Mi 27 NU:") + // This is a display name line (has indentation and ends with colon, but no other content) + if strings.HasSuffix(trimmed, ":") && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { + // Check if this looks like a display name (simple name, not a key-value pair) + // Display names typically don't have spaces before the colon in the key part + namePart := strings.TrimSuffix(trimmed, ":") + if !strings.Contains(namePart, ":") && namePart != "" { + // Save previous display if it has resolution + if currentDisplay.resolution != "" { + displays = append(displays, currentDisplay) + } + // Start new display context + currentDisplay = displayContext{} + continue + } + } + + // Check if current display is built-in + if strings.Contains(trimmed, "Display Type: Built-in") || strings.Contains(trimmed, "Built-in: Yes") { + currentDisplay.isBuiltIn = true + } + + // Check if current display is main display + if strings.Contains(trimmed, "Main Display: Yes") { + currentDisplay.isMainDisplay = true + } + + // Extract resolution from Resolution line + if strings.Contains(trimmed, "Resolution:") { + parts := strings.Split(trimmed, ":") + if len(parts) >= 2 { + res := strings.TrimSpace(parts[1]) + // Extract resolution (e.g., "3840 x 2160", "3456 x 2234 Retina") + // Remove text after resolution like "(2160p/4K UHD 1 - Ultra High Definition)" or "Retina" + resParts := strings.Fields(res) + // Find the first two numeric fields (skip "x" if present) + var widthStr, heightStr string + for _, part := range resParts { + // Skip non-numeric parts like "x", "Retina", etc. + if _, err := strconv.Atoi(part); err == nil { + if widthStr == "" { + widthStr = part + } else if heightStr == "" { + heightStr = part + break // Found both width and height + } + } + } + if widthStr != "" && heightStr != "" { + currentDisplay.resolution = widthStr + "x" + heightStr + } + } + } + } + + // Save last display if it has resolution + if currentDisplay.resolution != "" { + displays = append(displays, currentDisplay) + } + + // Find resolutions based on priority + for _, display := range displays { + if display.resolution == "" { + continue + } + if firstResolution == "" { + firstResolution = display.resolution + } + if display.isBuiltIn && builtInResolution == "" { + builtInResolution = display.resolution + } + if display.isMainDisplay && mainDisplayResolution == "" { + mainDisplayResolution = display.resolution + } + } + + // Use priority: Built-in > Main Display > First + var resolution string + if builtInResolution != "" { + resolution = builtInResolution + } else if mainDisplayResolution != "" { + resolution = mainDisplayResolution + } else if firstResolution != "" { + resolution = firstResolution + } + + if resolution == "" { + return 0, 0, fmt.Errorf("could not determine display resolution") + } + + // Parse resolution string (e.g., "3840x2160") + parts := strings.Split(resolution, "x") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid resolution format: %s", resolution) + } + + width, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return 0, 0, fmt.Errorf("invalid width: %s", parts[0]) + } + + height, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return 0, 0, fmt.Errorf("invalid height: %s", parts[1]) + } + + return width, height, nil +} + +// getLinuxDisplayResolution gets the primary display resolution on Linux +func getLinuxDisplayResolution() (int, int, error) { + // Try xrandr first (most common) + cmd := exec.Command("xrandr") + output, err := cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + for _, line := range lines { + // Look for line with "*" (current resolution) or "connected primary" + if strings.Contains(line, "connected primary") || strings.Contains(line, "*") { + // Parse resolution from line like " 1920x1080 60.00*+" + fields := strings.Fields(line) + for _, field := range fields { + if strings.Contains(field, "x") { + parts := strings.Split(field, "x") + if len(parts) == 2 { + width, err1 := strconv.Atoi(parts[0]) + height, err2 := strconv.Atoi(strings.TrimSuffix(parts[1], "*+")) + if err1 == nil && err2 == nil { + return width, height, nil + } + } + } + } + } + } + } + + // Fallback: try wayland-info or other methods + return 0, 0, fmt.Errorf("could not determine display resolution") +} + +// getWindowsDisplayResolution gets the primary display resolution on Windows +func getWindowsDisplayResolution() (int, int, error) { + // Use PowerShell to get display resolution + cmd := exec.Command("powershell", "-Command", "Get-WmiObject -Class Win32_VideoController | Select-Object -First 1 | Select-Object -ExpandProperty CurrentHorizontalResolution, CurrentVerticalResolution") + output, err := cmd.Output() + if err != nil { + return 0, 0, err + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) >= 2 { + width, err1 := strconv.Atoi(strings.TrimSpace(lines[0])) + height, err2 := strconv.Atoi(strings.TrimSpace(lines[1])) + if err1 == nil && err2 == nil { + return width, height, nil + } + } + + return 0, 0, fmt.Errorf("could not determine display resolution") +} + +// SetRegId writes regId to local file for desktop devices +func (m *DesktopManager) SetRegId(deviceID string, regId string) error { + return writeLocalRegId(regId) +} + +// GetRegId reads regId from local file for desktop devices +func (m *DesktopManager) GetRegId(deviceID string) (string, error) { + return readLocalRegId() +} + +// GetDevices returns empty list for desktop devices (not applicable) +func (m *DesktopManager) GetDevices() ([]DeviceInfo, error) { + return []DeviceInfo{}, nil +} + +// GetOSVersion returns the OS version for desktop devices +func (m *DesktopManager) GetOSVersion(deviceID string) (string, error) { + switch m.osType { + case "macos": + cmd := exec.Command("sw_vers", "-productVersion") + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil + case "linux": + // Try to get distribution version from /etc/os-release + cmd := exec.Command("cat", "/etc/os-release") + output, err := cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "VERSION_ID=") { + version := strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"") + return version, nil + } + } + } + return "", fmt.Errorf("failed to get Linux version") + case "windows": + // Use PowerShell to get Windows version + cmd := exec.Command("powershell", "-Command", "(Get-CimInstance Win32_OperatingSystem).Version") + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil + default: + return "", fmt.Errorf("unsupported OS type: %s", m.osType) + } +} + +// GetMemory returns the total memory in GB for desktop devices +func (m *DesktopManager) GetMemory(deviceID string) (string, error) { + switch m.osType { + case "macos": + // Try system_profiler first + cmd := exec.Command("system_profiler", "SPHardwareDataType") + output, err := cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "Memory:") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + return strings.TrimSpace(parts[1]), nil + } + } + } + } + // Fallback: calculate from sysctl + cmd2 := exec.Command("sysctl", "-n", "hw.memsize") + output2, err2 := cmd2.Output() + if err2 == nil { + memBytes, err := strconv.ParseInt(strings.TrimSpace(string(output2)), 10, 64) + if err == nil { + memGB := float64(memBytes) / (1024 * 1024 * 1024) + return fmt.Sprintf("%.0f GB", memGB), nil + } + } + return "", fmt.Errorf("failed to get macOS memory") + case "linux": + // Read from /proc/meminfo + cmd := exec.Command("cat", "/proc/meminfo") + output, err := cmd.Output() + if err != nil { + return "", err + } + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "MemTotal:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + memKB, err := strconv.ParseInt(fields[1], 10, 64) + if err == nil { + memGB := float64(memKB) / (1024 * 1024) + return fmt.Sprintf("%.0f GB", memGB), nil + } + } + } + } + return "", fmt.Errorf("failed to parse memory information") + case "windows": + // Use PowerShell to get total memory + cmd := exec.Command("powershell", "-Command", "$totalRAM = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory; [math]::Round($totalRAM / 1GB, 0)") + output, err := cmd.Output() + if err != nil { + return "", err + } + memGB := strings.TrimSpace(string(output)) + return fmt.Sprintf("%s GB", memGB), nil + default: + return "", fmt.Errorf("unsupported OS type: %s", m.osType) + } +} + +// getLocalRegIdPath returns the file path for storing the reg_id on this machine. +func getLocalRegIdPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".gbox") + return filepath.Join(dir, "reg_id"), nil +} + +// readLocalRegId reads reg_id from ~/.gbox/reg_id if exists. +func readLocalRegId() (string, error) { + path, err := getLocalRegIdPath() + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + // Trim trailing spaces/newlines + s := strings.TrimSpace(string(data)) + return s, nil +} + +// writeLocalRegId writes reg_id into ~/.gbox/reg_id, creating directory if needed. +func writeLocalRegId(regId string) error { + path, err := getLocalRegIdPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(regId+"\n"), 0o600) +} diff --git a/packages/cli/internal/device/desktop_test.go b/packages/cli/internal/device/desktop_test.go new file mode 100644 index 00000000..36902e93 --- /dev/null +++ b/packages/cli/internal/device/desktop_test.go @@ -0,0 +1,251 @@ +package device + +import ( + "strconv" + "strings" + "testing" +) + +func TestGetMacOSDisplayResolution(t *testing.T) { + tests := []struct { + name string + input string + expectedWidth int + expectedHeight int + expectError bool + }{ + { + name: "Built-in display with Retina", + input: `Graphics/Displays: + + Apple M4 Max: + + Chipset Model: Apple M4 Max + Type: GPU + Bus: Built-In + Displays: + Color LCD: + Display Type: Built-in Liquid Retina XDR Display + Resolution: 3456 x 2234 Retina + Mirror: Off + Online: Yes + Mi 27 NU: + Resolution: 3840 x 2160 (2160p/4K UHD 1 - Ultra High Definition) + Main Display: Yes + Mirror: Off`, + expectedWidth: 3456, + expectedHeight: 2234, + expectError: false, + }, + { + name: "Main Display priority when no Built-in", + input: `Graphics/Displays: + + Apple GPU: + + Displays: + External Display: + Resolution: 3840 x 2160 (2160p/4K UHD 1 - Ultra High Definition) + Main Display: Yes + Mirror: Off + Color LCD: + Resolution: 2560 x 1440 + Mirror: Off`, + expectedWidth: 3840, + expectedHeight: 2160, + expectError: false, + }, + { + name: "First display when no Built-in or Main Display", + input: `Graphics/Displays: + + Apple GPU: + + Displays: + Display 1: + Resolution: 1920 x 1080 + Mirror: Off + Display 2: + Resolution: 2560 x 1440 + Mirror: Off`, + expectedWidth: 1920, + expectedHeight: 1080, + expectError: false, + }, + { + name: "Built-in takes priority over Main Display", + input: `Graphics/Displays: + + Apple GPU: + + Displays: + External Display: + Resolution: 3840 x 2160 + Main Display: Yes + Color LCD: + Display Type: Built-in Liquid Retina XDR Display + Resolution: 3456 x 2234 Retina`, + expectedWidth: 3456, + expectedHeight: 2234, + expectError: false, + }, + { + name: "No displays section", + input: `Graphics/Displays: + + Apple GPU: + + Chipset Model: Apple GPU`, + expectError: true, + }, + { + name: "No resolution found", + input: `Graphics/Displays: + + Apple GPU: + + Displays: + Color LCD: + Display Type: Built-in + Mirror: Off`, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse the test input using the same logic as getMacOSDisplayResolution + lines := strings.Split(tt.input, "\n") + var builtInResolution string + var mainDisplayResolution string + var firstResolution string + + type displayContext struct { + isBuiltIn bool + isMainDisplay bool + resolution string + } + + var currentDisplay displayContext + displays := []displayContext{} + + inDisplaysSection := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if strings.Contains(trimmed, "Displays:") { + inDisplaysSection = true + continue + } + + if !inDisplaysSection { + continue + } + + if strings.HasSuffix(trimmed, ":") && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { + namePart := strings.TrimSuffix(trimmed, ":") + if !strings.Contains(namePart, ":") && namePart != "" { + if currentDisplay.resolution != "" { + displays = append(displays, currentDisplay) + } + currentDisplay = displayContext{} + continue + } + } + + if strings.Contains(trimmed, "Display Type: Built-in") || strings.Contains(trimmed, "Built-in: Yes") { + currentDisplay.isBuiltIn = true + } + + if strings.Contains(trimmed, "Main Display: Yes") { + currentDisplay.isMainDisplay = true + } + + if strings.Contains(trimmed, "Resolution:") { + parts := strings.Split(trimmed, ":") + if len(parts) >= 2 { + res := strings.TrimSpace(parts[1]) + resParts := strings.Fields(res) + var widthStr, heightStr string + for _, part := range resParts { + // Try to parse as integer to skip non-numeric parts + if _, err := strconv.Atoi(part); err == nil { + if widthStr == "" { + widthStr = part + } else if heightStr == "" { + heightStr = part + break + } + } + } + if widthStr != "" && heightStr != "" { + currentDisplay.resolution = widthStr + "x" + heightStr + } + } + } + } + + if currentDisplay.resolution != "" { + displays = append(displays, currentDisplay) + } + + for _, display := range displays { + if display.resolution == "" { + continue + } + if firstResolution == "" { + firstResolution = display.resolution + } + if display.isBuiltIn && builtInResolution == "" { + builtInResolution = display.resolution + } + if display.isMainDisplay && mainDisplayResolution == "" { + mainDisplayResolution = display.resolution + } + } + + var resolution string + if builtInResolution != "" { + resolution = builtInResolution + } else if mainDisplayResolution != "" { + resolution = mainDisplayResolution + } else if firstResolution != "" { + resolution = firstResolution + } + + if resolution == "" { + if !tt.expectError { + t.Errorf("Expected resolution but got none") + } + return + } + + if tt.expectError { + t.Errorf("Expected error but got resolution: %s", resolution) + return + } + + parts := strings.Split(resolution, "x") + if len(parts) != 2 { + t.Errorf("Invalid resolution format: %s", resolution) + return + } + + width, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + t.Errorf("Failed to parse width: %v", err) + return + } + + height, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + t.Errorf("Failed to parse height: %v", err) + return + } + + if width != tt.expectedWidth || height != tt.expectedHeight { + t.Errorf("Expected %dx%d, got %dx%d", tt.expectedWidth, tt.expectedHeight, width, height) + } + }) + } +} diff --git a/packages/cli/internal/device/interfaces.go b/packages/cli/internal/device/interfaces.go new file mode 100644 index 00000000..8e3041f9 --- /dev/null +++ b/packages/cli/internal/device/interfaces.go @@ -0,0 +1,71 @@ +package device + +import ( + "os/exec" + "strings" +) + +// DeviceManager is the interface for device management operations +type DeviceManager interface { + GetIdentifiers(deviceID string) (Identifiers, error) + GetDisplayResolution(deviceID string) (int, int, error) + GetOSVersion(deviceID string) (string, error) + GetMemory(deviceID string) (string, error) + SetRegId(deviceID string, regId string) error + GetRegId(deviceID string) (string, error) + GetDevices() ([]DeviceInfo, error) +} + +// DeviceInfo contains device information +type DeviceInfo struct { + ID string `json:"id"` + Status string `json:"status"` + SerialNo string `json:"serialNo"` + AndroidID string `json:"androidId"` + Model string `json:"model"` + Manufacturer string `json:"manufacturer"` + ConnectionType string `json:"connectionType"` + IsRegistrable bool `json:"isRegistrable"` + RegId string `json:"regId"` +} + +// Identifiers contains key identifiers for a device. +// For Android devices, all fields are populated. +// For Desktop devices, AndroidID is nil (not applicable). +type Identifiers struct { + SerialNo string + AndroidID *string // nil for desktop devices, non-nil for Android devices + RegId string +} + +// NewManager creates a new device manager based on osType +// osType can be "android", "linux", "windows", "macos", or empty (defaults to "android") +func NewManager(osType string) DeviceManager { + if osType == "" { + osType = "android" + } + + switch strings.ToLower(osType) { + case "android": + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + return &AndroidManager{ + adbPath: adbPath, + } + case "linux", "windows", "macos": + return &DesktopManager{ + osType: strings.ToLower(osType), + } + default: + // Default to Android for backward compatibility + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + return &AndroidManager{ + adbPath: adbPath, + } + } +} diff --git a/packages/cli/internal/device_connect/appium_installer.go b/packages/cli/internal/device_connect/appium_installer.go index 57abd2b7..5a71fc14 100644 --- a/packages/cli/internal/device_connect/appium_installer.go +++ b/packages/cli/internal/device_connect/appium_installer.go @@ -177,29 +177,19 @@ func InstallAppium(cfg AppiumConfig) error { debug := os.Getenv("DEBUG") == "true" // Check if Appium is already installed - fmt.Println() - fmt.Println("📦 Checking Appium server...") if IsAppiumInstalled(appiumHome) { - // Get Appium version - appiumBinary := filepath.Join(appiumHome, "node_modules", ".bin", "appium") - version := "" - if versionCmd := exec.Command(appiumBinary, "-v"); versionCmd != nil { - versionCmd.Env = append(os.Environ(), "APPIUM_HOME="+appiumHome) - if versionOutput, err := versionCmd.Output(); err == nil { - version = strings.TrimSpace(string(versionOutput)) - } + // Check if components need installation + err := installAppiumComponents(appiumHome, cfg) + if err != nil { + return err } - - if version != "" { - fmt.Printf("✅ Appium server [%s] is already installed\n", version) - } else { - fmt.Println("✅ Appium server is already installed") - } - - return installAppiumComponents(appiumHome, cfg) + // All components are already installed, no need to show messages + return nil } // Appium not installed + fmt.Println() + fmt.Println("📦 Checking Appium server...") fmt.Println("⚠️ Appium server not found, installing...") // Start spinner @@ -358,11 +348,9 @@ func installAppiumComponents(appiumHome string, cfg AppiumConfig) error { } var installErrors []string + var needsInstall bool // ===== Check and install drivers ===== - fmt.Println() - fmt.Println("📦 Checking Appium drivers...") - // Get currently installed drivers installedDrivers, err := getInstalledDrivers(appiumBinary, appiumHome) if err != nil { @@ -374,7 +362,7 @@ func installAppiumComponents(appiumHome string, cfg AppiumConfig) error { // Check configured drivers if len(cfg.Drivers) == 0 { - fmt.Println("ℹ️ No drivers configured") + // No drivers configured, nothing to check } else { // Check each configured driver var toInstall []string @@ -383,14 +371,21 @@ func installAppiumComponents(appiumHome string, cfg AppiumConfig) error { continue } if driverInfo, exists := installedDrivers[driver]; exists && driverInfo.Installed { - fmt.Printf("✅ Driver [%s@%s] is already installed\n", driver, driverInfo.Version) + // Driver already installed, no need to show message } else { toInstall = append(toInstall, driver) } } - // Install missing drivers + // Only show checking message if there are drivers to install if len(toInstall) > 0 { + if !needsInstall { + fmt.Println() + fmt.Println("📦 Checking Appium drivers...") + } + needsInstall = true + + // Install missing drivers fmt.Printf("⚠️ Missing drivers: %s, installing...\n", strings.Join(toInstall, ", ")) for _, driver := range toInstall { @@ -438,9 +433,6 @@ func installAppiumComponents(appiumHome string, cfg AppiumConfig) error { } // ===== Check and install plugins ===== - fmt.Println() - fmt.Println("📦 Checking Appium plugins...") - // Get currently installed plugins installedPlugins, err := getInstalledPlugins(appiumBinary, appiumHome) if err != nil { @@ -452,7 +444,7 @@ func installAppiumComponents(appiumHome string, cfg AppiumConfig) error { // Check configured plugins if len(cfg.Plugins) == 0 { - fmt.Println("ℹ️ No plugins configured") + // No plugins configured, nothing to check } else { // Check each configured plugin var toInstall []string @@ -461,14 +453,21 @@ func installAppiumComponents(appiumHome string, cfg AppiumConfig) error { continue } if pluginInfo, exists := installedPlugins[plugin]; exists && pluginInfo.Installed { - fmt.Printf("✅ Plugin [%s@%s] is already installed\n", plugin, pluginInfo.Version) + // Plugin already installed, no need to show message } else { toInstall = append(toInstall, plugin) } } - // Install missing plugins + // Only show checking message if there are plugins to install if len(toInstall) > 0 { + if !needsInstall { + fmt.Println() + fmt.Println("📦 Checking Appium plugins...") + } + needsInstall = true + + // Install missing plugins fmt.Printf("⚠️ Missing plugins: %s, installing...\n", strings.Join(toInstall, ", ")) for _, plugin := range toInstall { 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/control/clipboard.go b/packages/cli/internal/device_connect/control/clipboard.go new file mode 100644 index 00000000..41dd8d06 --- /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("[DEBUG] 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..03c64c0b --- /dev/null +++ b/packages/cli/internal/device_connect/control/control.go @@ -0,0 +1,230 @@ +package control + +import ( + "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/device_connect/scrcpy" + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// ControlService 控制服务 +type ControlService struct { + // 直接使用 scrcpy 全局管理器获取设备源 +} + +// 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) + + util.GetLogger().Debug("Touch event received", "device", deviceSerial, "action", action, "x", x, "y", y, "pressure", pressure, "pointerId", pointerId) + + // 获取设备的 source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + util.GetLogger().Warn("Device source not found", "device", deviceSerial) + return nil + } + + // 获取设备屏幕尺寸(用于坐标转换) + _, screenWidth, screenHeight := source.GetConnectionInfo() + if screenWidth == 0 || screenHeight == 0 { + util.GetLogger().Warn("Unknown screen size, using default", "device", deviceSerial) + screenWidth, screenHeight = 1080, 1920 // 默认尺寸 + } + + // 创建触摸事件,复用 WebRTC 模式的控制逻辑 + touchEvent := protocol.TouchEvent{ + Action: action, + X: x, + Y: y, + Pressure: pressure, + PointerID: int(pointerId), + } + + // 编码触摸事件(需要屏幕尺寸进行坐标转换) + data := protocol.EncodeTouchEvent(touchEvent, screenWidth, screenHeight) + + // 创建控制消息 + controlMsg := core.ControlMessage{ + Type: int32(protocol.ControlMsgTypeInjectTouchEvent), + Data: data, + } + + // 发送到设备 + if err := source.SendControl(controlMsg); err != nil { + util.GetLogger().Error("Failed to send touch event", "device", deviceSerial, "error", err) + return err + } + + util.GetLogger().Debug("Touch event sent successfully", "device", deviceSerial, "action", action) + 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) + + util.GetLogger().Debug("Key event", "device", deviceSerial, "action", action, "keycode", keycode, "metaState", metaState) + + // 获取设备的 source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + util.GetLogger().Warn("Device source not found", "device", deviceSerial) + return nil + } + + // 创建按键事件,复用 WebRTC 模式的控制逻辑 + keyEvent := protocol.KeyEvent{ + Action: action, + Keycode: int(keycode), + MetaState: int(metaState), + Repeat: 0, // H264 模式暂时不支持 repeat + } + + // 编码按键事件 + data := protocol.EncodeKeyEvent(keyEvent) + + // 创建控制消息 + controlMsg := core.ControlMessage{ + Type: int32(protocol.ControlMsgTypeInjectKeycode), + Data: data, + } + + // 发送到设备 + if err := source.SendControl(controlMsg); err != nil { + util.GetLogger().Error("Failed to send key event", "device", deviceSerial, "error", err) + return err + } + + util.GetLogger().Debug("Key event sent successfully", "device", deviceSerial) + 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) + + util.GetLogger().Debug("Scroll event", "device", deviceSerial, "x", x, "y", y, "hScroll", hScroll, "vScroll", vScroll) + + // 获取设备的 source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + util.GetLogger().Warn("Device source not found", "device", deviceSerial) + return nil + } + + // 获取设备屏幕尺寸(用于坐标转换) + _, screenWidth, screenHeight := source.GetConnectionInfo() + if screenWidth == 0 || screenHeight == 0 { + util.GetLogger().Warn("Unknown screen size, using default", "device", deviceSerial) + screenWidth, screenHeight = 1080, 1920 // 默认尺寸 + } + + // 创建滚动事件,复用 WebRTC 模式的控制逻辑 + scrollEvent := protocol.ScrollEvent{ + X: x, + Y: y, + HScroll: hScroll, + VScroll: vScroll, + } + + // 编码滚动事件(需要屏幕尺寸进行坐标转换) + data := protocol.EncodeScrollEvent(scrollEvent, screenWidth, screenHeight) + + // 创建控制消息 + controlMsg := core.ControlMessage{ + Type: int32(protocol.ControlMsgTypeInjectScrollEvent), + Data: data, + } + + // 发送到设备 + if err := source.SendControl(controlMsg); err != nil { + util.GetLogger().Error("Failed to send scroll event", "device", deviceSerial, "error", err) + return err + } + + util.GetLogger().Debug("Scroll event sent successfully", "device", deviceSerial) + return nil +} + +// HandleClipboardEvent 处理剪贴板事件 +func (s *ControlService) HandleClipboardEvent(msg map[string]interface{}, deviceSerial string) error { + text, _ := msg["text"].(string) + paste, _ := msg["paste"].(bool) + + util.GetLogger().Debug("Clipboard event", "device", deviceSerial, "text_length", len(text), "paste", paste) + + // TODO: Forward clipboard event to bridge manager + // This will be implemented when bridge integration is ready + util.GetLogger().Warn("Clipboard event received but bridge integration not yet implemented") + + return nil +} + +// HandleVideoResetEvent 处理视频重置事件 +func (s *ControlService) HandleVideoResetEvent(msg map[string]interface{}, deviceSerial string) error { + util.GetLogger().Debug("Reset video event", "device", deviceSerial) + + // 获取设备的 source + source := scrcpy.GetSource(deviceSerial) + if source == nil { + util.GetLogger().Warn("Device source not found", "device", deviceSerial) + return nil + } + + // 请求关键帧,复用 WebRTC 模式的控制逻辑 + // 创建一个空的控制消息,类型为重置视频 + controlMsg := core.ControlMessage{ + Type: int32(protocol.ControlMsgTypeResetVideo), + Data: []byte{}, // 视频重置不需要额外数据 + } + + // 发送到设备 + if err := source.SendControl(controlMsg); err != nil { + util.GetLogger().Error("Failed to send video reset event", "device", deviceSerial, "error", err) + return err + } + + util.GetLogger().Debug("Video reset event sent successfully", "device", deviceSerial) + return nil +} + +// HandleWebRTCEvent 处理 WebRTC 事件 +func (s *ControlService) HandleWebRTCEvent(msg map[string]interface{}, deviceSerial string) error { + msgType, _ := msg["type"].(string) + + util.GetLogger().Debug("WebRTC event", "device", deviceSerial, "type", msgType) + + // TODO: Forward WebRTC event to WebRTC handler + // This will be implemented when WebRTC integration is ready + util.GetLogger().Warn("WebRTC event received but WebRTC integration not yet implemented") + + return nil +} + +// 全局控制服务实例 +var controlService *ControlService + +// GetControlService 获取控制服务实例 +func GetControlService() *ControlService { + return controlService +} + +// SetControlService 设置控制服务实例 +func SetControlService() { + controlService = NewControlService() +} 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..9073fb2f --- /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("[DEBUG] 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..8b9999d4 --- /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("[DEBUG] 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..94b0fa17 --- /dev/null +++ b/packages/cli/internal/device_connect/control/touch.go @@ -0,0 +1,85 @@ +package control + +import ( + "github.com/babelcloud/gbox/packages/cli/internal/util" +) + +// 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) + + util.GetLogger().Debug("Touch event", "device", deviceSerial, "action", action, "x", x, "y", y, "pressure", pressure, "pointerId", 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/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/daemon.go b/packages/cli/internal/device_connect/daemon.go deleted file mode 100644 index 500b27ca..00000000 --- a/packages/cli/internal/device_connect/daemon.go +++ /dev/null @@ -1,330 +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)) - - // Add APPIUM_HOME environment variable for Appium integration - deviceProxyHome := config.GetDeviceProxyHome() - appiumHome := filepath.Join(deviceProxyHome, "appium") - env = append(env, fmt.Sprintf("APPIUM_HOME=%s", appiumHome)) - - // 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_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 new file mode 100644 index 00000000..000d60e2 --- /dev/null +++ b/packages/cli/internal/device_connect/device/connection.go @@ -0,0 +1,353 @@ +package device + +import ( + "fmt" + "log" + "net" + "os" + "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 + 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 { + 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" + } + + // Select optimal encoder based on streaming mode + videoEncoder := selectVideoEncoder(streamingMode) + + return &ScrcpyConnection{ + 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" + } +} + +// 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 + // 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) + } + + // 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 on port %d...", sc.scid) + + // 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 after %v", timeout) + } + 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) + + // Build command arguments, codec selection depends on streaming mode + 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", + "control=true", + "cleanup=true", + "log_level=verbose", // Enable verbose logging to debug scroll issues + "video_codec_options=i-frame-interval=2", + "video_codec=h264", + "video_encoder=c2.qti.avc.encoder", + } + + // Select audio codec by mode: + // - separated (webm) and webrtc modes: use Opus + // - muxed (mp4) mode: use AAC + switch sc.streamingMode { + case "webm", "webrtc", "mse": + // Separated and WebRTC modes use Opus for better WebRTC compatibility + args = append(args, + "audio_codec=opus", + "audio_encoder=c2.android.opus.encoder", + ) + case "mp4": + // Muxed mode uses AAC for MP4 container compatibility + args = append(args, + "audio_codec=aac", + "audio_encoder=c2.android.aac.encoder", + ) + default: + // Default to Opus for unknown modes (better compatibility) + args = append(args, + "audio_codec=opus", + "audio_encoder=c2.android.opus.encoder", + ) + } + + cmd := exec.Command(sc.adbPath, args...) + + log.Printf("Starting scrcpy server with command: %s", cmd.String()) + + // Start the command + sc.serverCmd = cmd + + // Capture output for debugging + 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) + } + + // 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 { + // Note: embedded assets will be handled differently + + // Fallback to external files + 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) + return absPath + } + } + + return "" +} + +// Note: embedded server extraction removed - using external files only 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/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 fe22e57a..00000000 --- a/packages/cli/internal/device_connect/kill.go +++ /dev/null @@ -1,210 +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 { - // On Unix, the device proxy is started in its own process group. To ensure - // all child processes (e.g., frpc) are terminated, send the signal to the - // process group using a negative PID first, then fall back to the single PID. - if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { - // Try killing the entire process group - if force { - if err := exec.Command("kill", "-9", fmt.Sprintf("-%d", pid)).Run(); err == nil { - return nil - } - // Fall back to killing only the leader - return exec.Command("kill", "-9", fmt.Sprintf("%d", pid)).Run() - } - if err := exec.Command("kill", fmt.Sprintf("-%d", pid)).Run(); err == nil { - return nil - } - return exec.Command("kill", fmt.Sprintf("%d", pid)).Run() - } - - // Windows behavior unchanged - var cmd *exec.Cmd - if force { - switch runtime.GOOS { - 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 "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/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..7530adf1 --- /dev/null +++ b/packages/cli/internal/device_connect/pipeline/pipeline.go @@ -0,0 +1,164 @@ +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() + + if len(p.audioSubs) == 0 { + util.GetLogger().Debug("🎵 No audio subscribers, dropping sample", "size", len(sample.Data)) + return + } + + // Track successful sends + successfulSends := 0 + totalSubscribers := len(p.audioSubs) + + // Create a list of subscribers to remove (dead channels) + var deadSubscribers []string + + for id, ch := range p.audioSubs { + select { + case ch <- sample: + util.GetLogger().Debug("🎵 Audio sample sent to subscriber", "subscriber", id, "size", len(sample.Data)) + successfulSends++ + default: + // Channel is full, check if it's a dead channel + select { + case <-ch: + // Channel was closed, mark for removal + deadSubscribers = append(deadSubscribers, id) + util.GetLogger().Warn("🎵 Dead audio subscriber detected, marking for removal", "subscriber", id) + default: + // Channel is just full, not dead + util.GetLogger().Warn("🎵 Audio channel full, dropping sample", "subscriber", id) + } + } + } + + // Remove dead subscribers + if len(deadSubscribers) > 0 { + p.mu.RUnlock() // Release read lock + p.mu.Lock() // Acquire write lock + for _, id := range deadSubscribers { + if ch, exists := p.audioSubs[id]; exists { + close(ch) + delete(p.audioSubs, id) + util.GetLogger().Info("🎵 Dead audio subscriber removed", "id", id, "total", len(p.audioSubs)) + } + } + p.mu.Unlock() // Release write lock + p.mu.RLock() // Re-acquire read lock for consistency + } + + // If no subscribers could accept the sample, log a warning + if successfulSends == 0 { + util.GetLogger().Warn("🎵 All audio channels full, dropping sample", + "total_subscribers", totalSubscribers, + "sample_size", len(sample.Data)) + } +} 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..e2d1b35c --- /dev/null +++ b/packages/cli/internal/device_connect/protocol/control.go @@ -0,0 +1,217 @@ +package protocol + +import ( + "encoding/binary" +) + +// 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 (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/protocol/scrcpy.go b/packages/cli/internal/device_connect/protocol/scrcpy.go new file mode 100644 index 00000000..51bde8ab --- /dev/null +++ b/packages/cli/internal/device_connect/protocol/scrcpy.go @@ -0,0 +1,303 @@ +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 + IsConfig bool +} + +// 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) + } + + // In scrcpy, audio config frames are flagged like video using PACKET_FLAG_CONFIG + isConfig := (ptsFlags & PacketFlagConfig) != 0 + return &AudioPacket{ + PTS: ptsFlags & PacketPTSMask, + PacketSize: packetSize, + Data: data, + IsConfig: isConfig, + }, 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/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..94c6ea44 --- /dev/null +++ b/packages/cli/internal/device_connect/scrcpy/manager.go @@ -0,0 +1,154 @@ +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, check if streaming mode change requires restart + if src.streamingMode != streamingMode { + // Check if audio codec needs to change (requires server restart) + needsRestart := needsAudioCodecRestart(src.streamingMode, streamingMode) + + if needsRestart { + util.GetLogger().Info("Audio codec change detected, restarting scrcpy server", + "device", deviceSerial, "from", src.streamingMode, "to", streamingMode) + + // Stop the existing source to force restart with new codec + src.mu.Unlock() + src.Stop() + + // Remove from manager and create new one + globalManager.mu.Unlock() + globalManager.mu.Lock() + delete(globalManager.sources, deviceSerial) + } else { + // Just update the mode without restart + util.GetLogger().Info("Updating streaming mode without restart", + "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 + } + } else { + src.mu.Unlock() + util.GetLogger().Info("Using existing scrcpy source", "device", deviceSerial, "mode", streamingMode) + return src + } + } else { + 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] +} + +// needsAudioCodecRestart determines if a streaming mode change requires server restart +// due to audio codec differences +func needsAudioCodecRestart(fromMode, toMode string) bool { + // Define audio codec groups + aacModes := []string{"mp4", "muxed"} + + // Helper function to check if mode uses AAC codec + isAACMode := func(mode string) bool { + for _, m := range aacModes { + if mode == m { + return true + } + } + return false + } + + // Check if switching between different audio codec groups + fromIsAAC := isAACMode(fromMode) + toIsAAC := isAACMode(toMode) + + // If switching from AAC to Opus or vice versa, restart is needed + return fromIsAAC != toIsAAC +} 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..c39b4971 --- /dev/null +++ b/packages/cli/internal/device_connect/scrcpy/source.go @@ -0,0 +1,513 @@ +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().Warn("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 { + util.GetLogger().Error("Failed to send control message to device", "device", s.deviceSerial, "error", err) + return fmt.Errorf("failed to send control message: %w", err) + } + + util.GetLogger().Debug("Control message sent successfully", "device", s.deviceSerial, "msg_type", msg.Type) + 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.Info("🎵 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, and do NOT publish as video sample + if packet.IsConfig { + logger.Info("Config packet received - caching SPS/PPS", "device", s.deviceSerial, "size", len(packet.Data)) + s.mu.Lock() + s.spsPps = append([]byte{}, packet.Data...) + s.mu.Unlock() + s.pipeline.CacheSpsPps(packet.Data) + logger.Info("SPS/PPS cached successfully", "device", s.deviceSerial, "size", len(packet.Data)) + continue + } + + // 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.Info("🎵 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 + } + logger.Info("✅ Audio metadata read successfully", "device", s.deviceSerial) + + // Start reading audio packets + packetCount := 0 + lastPacketTime := time.Now() + timeoutDuration := 30 * time.Second // 30 seconds timeout for no audio data + + logger.Info("🎵 Starting audio packet reading loop", "device", s.deviceSerial) + + for { + select { + case <-ctx.Done(): + logger.Debug("Audio stream context cancelled", "device", s.deviceSerial) + return + default: + } + + // Check for timeout - if no audio packets received for 30 seconds, log warning + if time.Since(lastPacketTime) > timeoutDuration && packetCount == 0 { + logger.Warn("🎵 No audio packets received for 30 seconds - device may not have audio activity or audio permissions may be missing", + "device", s.deviceSerial, + "timeout", timeoutDuration, + "packets_received", packetCount) + // Continue waiting, don't return - audio might start later + } + + // 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, "total_packets", packetCount) + } 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, "packets_received", packetCount) + } + return + } + + // Update packet count and timestamp + packetCount++ + lastPacketTime = time.Now() + + // Skip audio config packets (they are not media samples) + if packet.IsConfig { + continue + } + + // Create audio sample + sample := core.AudioSample{ + Data: packet.Data, + PTS: int64(packet.PTS), + } + + // Log first few audio packets and progress + if len(sample.Data) > 0 { + if packetCount <= 5 { + logger.Info("🎵 Audio packet received", "device", s.deviceSerial, "packet", packetCount, "size", len(sample.Data), "pts", sample.PTS) + } else if packetCount%100 == 0 { + logger.Debug("🎵 Audio streaming progress", "device", s.deviceSerial, "packets", packetCount, "size", len(sample.Data)) + } + } else { + logger.Warn("🎵 Empty audio packet received", "device", s.deviceSerial, "packet", packetCount) + } + + // 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/transport/audio/streaming.go b/packages/cli/internal/device_connect/transport/audio/streaming.go new file mode 100644 index 00000000..dbf5b286 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/audio/streaming.go @@ -0,0 +1,335 @@ +package audio + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "sync" + "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 +} + +// ConnectionHealthMonitor monitors HTTP connection health for early disconnection detection +type ConnectionHealthMonitor struct { + writer http.ResponseWriter + flusher http.Flusher + logger *slog.Logger + interval time.Duration + stopChan chan struct{} + stopped bool + mu sync.RWMutex + healthy bool +} + +// Start begins monitoring the connection health +func (chm *ConnectionHealthMonitor) Start() { + chm.mu.Lock() + defer chm.mu.Unlock() + + if chm.stopped { + return + } + + chm.stopChan = make(chan struct{}) + chm.healthy = true + + go chm.monitor() +} + +// Stop stops the health monitoring +func (chm *ConnectionHealthMonitor) Stop() { + chm.mu.Lock() + defer chm.mu.Unlock() + + if chm.stopped { + return + } + + chm.stopped = true + if chm.stopChan != nil { + close(chm.stopChan) + } +} + +// IsHealthy returns whether the connection is still healthy +func (chm *ConnectionHealthMonitor) IsHealthy() bool { + chm.mu.RLock() + defer chm.mu.RUnlock() + return chm.healthy +} + +// monitor runs the health monitoring loop +func (chm *ConnectionHealthMonitor) monitor() { + ticker := time.NewTicker(chm.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if !chm.checkHealth() { + chm.mu.Lock() + chm.healthy = false + chm.mu.Unlock() + chm.logger.Info("🎵 Connection health check failed, marking as unhealthy") + return + } + case <-chm.stopChan: + return + } + } +} + +// checkHealth performs a health check without interfering with data flushing +func (chm *ConnectionHealthMonitor) checkHealth() bool { + defer func() { + if r := recover(); r != nil { + chm.logger.Warn("🎵 Health check panic recovered", "panic", r) + // Mark as unhealthy if health check panics + chm.mu.Lock() + chm.healthy = false + chm.mu.Unlock() + } + }() + + // Don't flush here to avoid conflicts with data stream flushing + // Just check if the connection is still alive by checking the writer + // This is a non-intrusive health check + return true +} + +// 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) error { + logger := slog.With("device", deviceSerial) + logger.Info("🎵 Starting raw Opus audio stream") + + // 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 raw Opus streaming") + + // Subscribe to audio stream + subscriberID := fmt.Sprintf("audio_raw_opus_%p", writer) + audioCh := source.SubscribeAudio(subscriberID, 100) + defer source.UnsubscribeAudio(subscriberID) + + logger.Info("🎵 Subscribed to raw Opus stream", "subscriberID", subscriberID) + + sampleCount := 0 + for sample := range audioCh { + sampleCount++ + + // Skip empty samples + if len(sample.Data) == 0 { + continue + } + + // Write raw Opus data directly without WebM container + if _, err := writer.Write(sample.Data); err != nil { + if err == io.ErrClosedPipe { + logger.Info("🎵 Client disconnected, stopping raw audio stream", "frames_sent", sampleCount) + } else { + logger.Error("❌ Failed to write raw Opus data", "error", err, "frame", sampleCount) + } + return err + } + + // Log successful transmission for first few frames + if sampleCount <= 5 { + logger.Info("✅ Successfully sent raw Opus data", "count", sampleCount, "size", len(sample.Data)) + } + } + + return nil +} + +// StreamWebM streams Opus audio as WebM container +func (s *AudioStreamingService) StreamWebM(deviceSerial string, w http.ResponseWriter, r *http.Request) error { + logger := slog.With("component", "webm_streaming", "device", deviceSerial) + logger.Info("🎵 Starting WebM audio stream") + + // Ensure we can flush chunks + flusher, ok := w.(http.Flusher) + if !ok { + return fmt.Errorf("streaming not supported") + } + + // Set up client disconnect detection + closeNotifier, ok := w.(http.CloseNotifier) + var closeNotify <-chan bool + if ok { + closeNotify = closeNotifier.CloseNotify() + logger.Info("🎵 Client disconnect detection enabled") + } else { + logger.Warn("🎵 CloseNotifier not available, using context only") + } + + // Create connection health monitor + healthMonitor := &ConnectionHealthMonitor{ + writer: w, + flusher: flusher, + logger: logger, + interval: 500 * time.Millisecond, // Check every 500ms + } + healthMonitor.Start() + defer healthMonitor.Stop() + + // 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 stability + subscriberID := fmt.Sprintf("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 WebM muxer + muxer := NewWebMMuxer(w) + defer muxer.Close() + + // Write WebM header immediately + 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 + connectionLost := false + + // Stream audio frames with improved error recovery + for { + select { + case sample, ok := <-audioCh: + if !ok { + logger.Info("🎵 Audio channel closed") + return nil + } + + // Skip empty samples + if len(sample.Data) == 0 { + continue + } + + // Check connection health before processing + if !healthMonitor.IsHealthy() || connectionLost { + if !connectionLost { + logger.Info("🎵 Connection marked as unhealthy, stopping stream", "frames_sent", frameCount) + connectionLost = true + } + return nil + } + + // Check for backpressure - if channel is getting full, skip some samples + if len(audioCh) > 800 { // 80% of buffer size + logger.Warn("🎵 Audio buffer backpressure detected, skipping sample", + "buffer_usage", len(audioCh), "buffer_size", 1000) + continue + } + + // Calculate relative timestamp + timestamp := time.Since(startTime) + + // Check if muxer is still valid before attempting to write + if muxer == nil { + logger.Warn("🎵 Muxer is nil, skipping frame", "frame", frameCount) + continue + } + + // Write Opus frame with simple error handling (no retries) + writeSuccess := false + 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 WebM streaming", "frames_sent", frameCount) + } else { + logger.Warn("🎵 Write failed, stopping stream for client reconnect", "error", writeErr, "frame", frameCount) + } + // Stop streaming to trigger client reconnection + muxer = nil + connectionLost = true + } else { + writeSuccess = true + } + + // If muxer was set to nil due to panic, stop streaming + if muxer == nil { + logger.Info("🎵 WebM muxer failed, stopping stream", "frames_sent", frameCount) + connectionLost = true + return nil + } + + // If write failed, stop streaming + if !writeSuccess { + logger.Warn("🎵 Write failed, stopping stream", "frame", frameCount) + return nil + } + + frameCount++ + + // Force flush every 200ms for better stability (reduced frequency) + now := time.Now() + if now.Sub(lastFlushTime) >= 200*time.Millisecond { + flusher.Flush() + lastFlushTime = now + + // Log progress every 5 seconds + if frameCount%250 == 0 { // ~5s at 20ms frames + stats := muxer.GetStats() + logger.Info("🎵 WebM streaming progress", + "frames", frameCount, + "duration", timestamp.Truncate(time.Millisecond), + "stats", stats) + } + } + + case <-r.Context().Done(): + logger.Info("🎵 Request context cancelled", "frames_sent", frameCount) + return nil + + case <-closeNotify: + logger.Info("🎵 Client disconnected via CloseNotify", "frames_sent", frameCount) + return nil + } + } +} + +// 全局音频流服务实例 +var audioService *AudioStreamingService + +// GetAudioService 获取音频流服务实例 +func GetAudioService() *AudioStreamingService { + if audioService == nil { + audioService = NewAudioStreamingService() + } + return audioService +} diff --git a/packages/cli/internal/device_connect/transport/audio/streaming_test.go b/packages/cli/internal/device_connect/transport/audio/streaming_test.go new file mode 100644 index 00000000..bb855e0a --- /dev/null +++ b/packages/cli/internal/device_connect/transport/audio/streaming_test.go @@ -0,0 +1,302 @@ +package audio + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" +) + +// MockAudioSource implements core.Source for testing +type MockAudioSource struct { + audioChannel chan core.AudioSample + subscribers map[string]chan core.AudioSample +} + +func NewMockAudioSource() *MockAudioSource { + return &MockAudioSource{ + audioChannel: make(chan core.AudioSample, 100), + subscribers: make(map[string]chan core.AudioSample), + } +} + +func (m *MockAudioSource) SubscribeAudio(subscriberID string, bufferSize int) <-chan core.AudioSample { + ch := make(chan core.AudioSample, bufferSize) + m.subscribers[subscriberID] = ch + return ch +} + +func (m *MockAudioSource) UnsubscribeAudio(subscriberID string) { + if ch, exists := m.subscribers[subscriberID]; exists { + close(ch) + delete(m.subscribers, subscriberID) + } +} + +func (m *MockAudioSource) GetConnectionInfo() map[string]interface{} { + return map[string]interface{}{ + "device_serial": "test_device", + "status": "connected", + } +} + +func (m *MockAudioSource) SendAudioSample(sample core.AudioSample) { + for _, ch := range m.subscribers { + select { + case ch <- sample: + default: + // Channel is full, skip this sample + } + } +} + +func (m *MockAudioSource) Close() { + for _, ch := range m.subscribers { + close(ch) + } +} + +// MockWriter implements io.Writer and tracks writes +type MockWriter struct { + bytes.Buffer + writeErrors []error + errorIndex int +} + +func NewMockWriter() *MockWriter { + return &MockWriter{} +} + +func (m *MockWriter) SetWriteErrors(errors []error) { + m.writeErrors = errors + m.errorIndex = 0 +} + +func (m *MockWriter) Write(p []byte) (n int, err error) { + // Simulate write errors if configured + if m.errorIndex < len(m.writeErrors) { + err = m.writeErrors[m.errorIndex] + m.errorIndex++ + if err != nil { + return 0, err + } + } + + return m.Buffer.Write(p) +} + +// TestWebMMuxer tests WebM muxer functionality +func TestWebMMuxer(t *testing.T) { + t.Run("WriteHeader", func(t *testing.T) { + writer := NewMockWriter() + muxer := NewWebMMuxer(writer) + + err := muxer.WriteHeader() + if err != nil { + t.Fatalf("WriteHeader failed: %v", err) + } + + if !muxer.initialized { + t.Error("Muxer should be initialized after WriteHeader") + } + + // Check that some data was written + if writer.Len() == 0 { + t.Error("No data written to buffer") + } + }) + + t.Run("WriteOpusFrame", func(t *testing.T) { + writer := NewMockWriter() + muxer := NewWebMMuxer(writer) + + // Initialize muxer + if err := muxer.WriteHeader(); err != nil { + t.Fatalf("WriteHeader failed: %v", err) + } + + // Write a test frame + testData := []byte("test opus data") + err := muxer.WriteOpusFrame(testData, 20*time.Millisecond) + if err != nil { + t.Fatalf("WriteOpusFrame failed: %v", err) + } + + stats := muxer.GetStats() + if stats["frames_written"] != uint64(1) { + t.Errorf("Expected 1 frame written, got %v", stats["frames_written"]) + } + }) + + t.Run("WriteOpusFrameWithoutHeader", func(t *testing.T) { + writer := NewMockWriter() + muxer := NewWebMMuxer(writer) + + // Try to write frame without initializing + testData := []byte("test opus data") + err := muxer.WriteOpusFrame(testData, 20*time.Millisecond) + if err != nil { + t.Fatalf("WriteOpusFrame should auto-initialize: %v", err) + } + + if !muxer.initialized { + t.Error("Muxer should be auto-initialized") + } + }) + + t.Run("Close", func(t *testing.T) { + writer := NewMockWriter() + muxer := NewWebMMuxer(writer) + + err := muxer.Close() + if err != nil { + t.Fatalf("Close failed: %v", err) + } + }) +} + +// TestWriterCloser tests the writer wrapper +func TestWriterCloser(t *testing.T) { + t.Run("NormalWrite", func(t *testing.T) { + writer := NewMockWriter() + swc := &writerCloser{ + writer: writer, + logger: slog.Default(), + closed: false, + } + + testData := []byte("test data") + n, err := swc.Write(testData) + if err != nil { + t.Fatalf("Write failed: %v", err) + } + + if n != len(testData) { + t.Errorf("Expected to write %d bytes, wrote %d", len(testData), n) + } + }) + + t.Run("WriteAfterClose", func(t *testing.T) { + writer := NewMockWriter() + swc := &writerCloser{ + writer: writer, + logger: slog.Default(), + closed: true, + } + + testData := []byte("test data") + _, err := swc.Write(testData) + if err != io.ErrClosedPipe { + t.Errorf("Expected ErrClosedPipe, got %v", err) + } + }) + + t.Run("WriteError", func(t *testing.T) { + writer := NewMockWriter() + writer.SetWriteErrors([]error{io.ErrShortWrite}) + + // Create a simple logger to avoid nil pointer panic + logger := slog.Default() + + swc := &writerCloser{ + writer: writer, + logger: logger, + closed: false, + } + + testData := []byte("test data") + _, err := swc.Write(testData) + if err != io.ErrShortWrite { + t.Errorf("Expected ErrShortWrite, got %v", err) + } + + if !swc.closed { + t.Error("Writer should be marked as closed after error") + } + }) +} + +// TestStreamWebM tests the WebM streaming functionality +func TestStreamWebM(t *testing.T) { + t.Run("DeviceNotFound", func(t *testing.T) { + req := httptest.NewRequest("GET", "/stream/audio/test_device?codec=opus&format=webm", nil) + req = req.WithContext(context.Background()) + rr := httptest.NewRecorder() + + service := NewAudioStreamingService() + err := service.StreamWebM("test_device", rr, req) + + if err == nil { + t.Error("Expected error for missing device, got nil") + } + + if !strings.Contains(err.Error(), "device source not found") { + t.Errorf("Expected 'device source not found' error, got: %v", err) + } + }) +} + +// TestConnectionHealthMonitor tests the connection health monitoring +func TestConnectionHealthMonitor(t *testing.T) { + t.Run("HealthyConnection", func(t *testing.T) { + rr := httptest.NewRecorder() + + monitor := &ConnectionHealthMonitor{ + writer: rr, + flusher: rr, + logger: slog.Default(), + interval: 100 * time.Millisecond, + } + + monitor.Start() + defer monitor.Stop() + + // Wait a bit and check health + time.Sleep(200 * time.Millisecond) + + if !monitor.IsHealthy() { + t.Error("Connection should be healthy") + } + }) + + t.Run("StartStop", func(t *testing.T) { + rr := httptest.NewRecorder() + + monitor := &ConnectionHealthMonitor{ + writer: rr, + flusher: rr, + logger: slog.Default(), + interval: 100 * time.Millisecond, + } + + monitor.Start() + monitor.Stop() + + // Stop should not panic + monitor.Stop() + }) +} + +// Benchmark tests +func BenchmarkWebMMuxerWriteOpusFrame(b *testing.B) { + writer := NewMockWriter() + muxer := NewWebMMuxer(writer) + + if err := muxer.WriteHeader(); err != nil { + b.Fatalf("WriteHeader failed: %v", err) + } + + testData := []byte("test opus data") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + timestamp := time.Duration(i) * 20 * time.Millisecond + muxer.WriteOpusFrame(testData, timestamp) + } +} 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..49b94828 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/audio/webm.go @@ -0,0 +1,187 @@ +package audio + +import ( + "fmt" + "io" + "log/slog" + "time" + + "github.com/at-wat/ebml-go/mkvcore" + "github.com/at-wat/ebml-go/webm" +) + +// WebMMuxer provides WebM container using at-wat/ebml-go +// Based on the official Pion WebRTC save-to-webm example +type WebMMuxer struct { + writer io.Writer + audioWriter webm.BlockWriteCloser + logger *slog.Logger + initialized bool + frameCount uint64 + audioTimestamp time.Duration +} + +// NewWebMMuxer creates a new WebM muxer +func NewWebMMuxer(writer io.Writer) *WebMMuxer { + return &WebMMuxer{ + writer: writer, + logger: slog.With("component", "webm_muxer"), + } +} + +// writerCloser wraps an io.Writer with basic error handling +type writerCloser struct { + writer io.Writer + logger *slog.Logger + closed bool +} + +func (wc *writerCloser) Write(p []byte) (n int, err error) { + if wc.closed { + return 0, io.ErrClosedPipe + } + + n, err = wc.writer.Write(p) + if err != nil { + wc.logger.Warn("Write error detected, marking writer as closed", + "error", err, + "error_type", fmt.Sprintf("%T", err), + "data_size", len(p), + "bytes_written", n) + wc.closed = true + } + return n, err +} + +func (wc *writerCloser) Close() error { + wc.closed = true + return nil +} + +// WriteHeader initializes the WebM container with audio track +func (m *WebMMuxer) WriteHeader() error { + if m.initialized { + return nil + } + + m.logger.Info("🎵 Initializing WebM container based on Pion example") + + // Wrap writer with basic error handling + writeCloser := &writerCloser{ + writer: m.writer, + logger: m.logger, + closed: false, + } + + // Create WebM writer with audio track configuration (matching Pion's example) + // Use custom fatal error handler to avoid panic + writers, err := webm.NewSimpleBlockWriter(writeCloser, []webm.TrackEntry{ + { + Name: "Audio", + TrackNumber: 1, + TrackUID: 1, // Use simple UID to avoid conflicts + 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 + }, + }, + }, mkvcore.WithOnFatalHandler(func(err error) { + m.logger.Warn("WebM error occurred, will trigger client reconnect", "error", err) + // Reset state for clean reconnection + m.initialized = false + m.audioWriter = nil + })) + + if err != nil { + m.logger.Error("Failed to create WebM writer", "error", err) + return err + } + + // Get the audio writer from the slice + m.audioWriter = writers[0] + m.initialized = true + + m.logger.Info("✅ WebM container initialized successfully") + return nil +} + +// WriteOpusFrame writes an Opus frame to the WebM container +func (m *WebMMuxer) WriteOpusFrame(opusData []byte, timestamp time.Duration) error { + // Early validation checks + if 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 + } + + // Update audio timestamp (cumulative duration) + // Using fixed 20ms duration for Opus frames (typical) + frameTimestamp := 20 * time.Millisecond + m.audioTimestamp += frameTimestamp + + // 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) + // Mark writer as closed on write error to prevent further attempts + m.audioWriter = nil + } + 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 *WebMMuxer) Close() error { + if m.audioWriter != nil { + m.logger.Info("🎵 Finalizing WebM container", "total_frames", m.frameCount) + + 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("✅ WebM muxer closed successfully") + return nil +} + +// GetStats returns muxer statistics +func (m *WebMMuxer) GetStats() map[string]interface{} { + return map[string]interface{}{ + "frames_written": m.frameCount, + "audio_duration_ms": int64(m.audioTimestamp / time.Millisecond), + "initialized": m.initialized, + "type": "webm", + } +} 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..ffe98757 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/annexb_to_avc.go @@ -0,0 +1,289 @@ +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 + } + + // Debug: Log first few conversions (can be removed in production) + // if len(data) >= 4 { + // firstBytes := fmt.Sprintf("%02x %02x %02x %02x", data[0], data[1], data[2], data[3]) + // if data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x00 && data[3] == 0x01 { + // fmt.Printf("[AnnexBToAVC] Converting Annex-B data (start code: %s), size: %d\n", firstBytes, len(data)) + // } else { + // fmt.Printf("[AnnexBToAVC] Converting non-Annex-B data (first bytes: %s), size: %d\n", firstBytes, len(data)) + // } + // } + + // 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) +} + +// PrependParameterSetsAVCC prepends SPS/PPS (Annex-B NAL payloads) to an AVCC-access unit +// sps, pps are raw NAL payloads (without start codes). Returns new AVCC buffer. +func PrependParameterSetsAVCC(avcc []byte, sps []byte, pps []byte) []byte { + if len(avcc) == 0 || len(sps) == 0 || len(pps) == 0 { + return avcc + } + // Build length-prefixed SPS and PPS + spsLen := uint32(len(sps)) + ppsLen := uint32(len(pps)) + out := make([]byte, 0, 4+len(sps)+4+len(pps)+len(avcc)) + out = append(out, byte(spsLen>>24), byte(spsLen>>16), byte(spsLen>>8), byte(spsLen)) + out = append(out, sps...) + out = append(out, byte(ppsLen>>24), byte(ppsLen>>16), byte(ppsLen>>8), byte(ppsLen)) + out = append(out, pps...) + out = append(out, avcc...) + return out +} + +// 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/handler_annexb.go b/packages/cli/internal/device_connect/transport/h264/handler_annexb.go new file mode 100644 index 00000000..86e7410d --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/handler_annexb.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" +) + +// AnnexBHandler handles HTTP-based Annex-B format H.264 streaming +type AnnexBHandler struct { + deviceSerial string +} + +// NewAnnexBHandler creates a new HTTP handler for Annex-B format H.264 streaming +func NewAnnexBHandler(deviceSerial string) *AnnexBHandler { + return &AnnexBHandler{ + deviceSerial: deviceSerial, + } +} + +// ServeHTTP implements http.Handler for Annex-B format H.264 streaming +func (h *AnnexBHandler) 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, 1000) + 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_avc.go b/packages/cli/internal/device_connect/transport/h264/handler_avc.go new file mode 100644 index 00000000..a7529eca --- /dev/null +++ b/packages/cli/internal/device_connect/transport/h264/handler_avc.go @@ -0,0 +1,148 @@ +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, "url", r.URL.String()) + + // 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, 1000) + defer source.UnsubscribeVideo(subscriberID) + + // Wait for SPS/PPS data to be available (with timeout) + spsPpsSent := false + maxWaitTime := 10 * time.Second + checkInterval := 100 * time.Millisecond + startTime := time.Now() + + for time.Since(startTime) < maxWaitTime { + spsPps := source.GetSpsPps() + if len(spsPps) > 0 { + logger.Info("Converting SPS/PPS to AVC format", "device", h.deviceSerial, "originalSize", len(spsPps)) + 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)) + spsPpsSent = true + break + } else { + logger.Warn("Empty AVC SPS/PPS after conversion", "device", h.deviceSerial) + } + } + time.Sleep(checkInterval) + } + + if !spsPpsSent { + logger.Warn("SPS/PPS data not available within timeout, continuing without it", "device", h.deviceSerial) + } + + // 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/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/stream/fmp4_muxer.go b/packages/cli/internal/device_connect/transport/stream/fmp4_muxer.go new file mode 100644 index 00000000..7709ff35 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/stream/fmp4_muxer.go @@ -0,0 +1,282 @@ +package stream + +import ( + "context" + "fmt" + "io" + "log/slog" + "sync" + "time" + + "github.com/bluenviron/mediacommon/v2/pkg/codecs/h264" + "github.com/bluenviron/mediacommon/v2/pkg/codecs/mpeg4audio" +) + +// min returns the smaller of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// FMP4Muxer adapts FMP4StreamWriter to Muxer interface +type FMP4Muxer struct { + *FMP4StreamWriter + initialized bool + mu sync.Mutex + codecParams *CodecParams +} + +// NewFMP4Muxer creates a new FMP4 muxer +func NewFMP4Muxer(writer io.Writer, logger *slog.Logger) *FMP4Muxer { + return &FMP4Muxer{ + FMP4StreamWriter: NewFMP4StreamWriter(writer, logger, 1920, 1080), // Default dimensions + } +} + +func (w *FMP4Muxer) Initialize(width, height int, params *CodecParams) error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.initialized { + return nil + } + + // 更新尺寸 + w.videoTrack.clockRate = 90000 + w.audioTrack.clockRate = 48000 + + // 如果缺少音频配置,使用合理的默认值(AAC LC, 48kHz, 2ch) + var audioCfg mpeg4audio.AudioSpecificConfig + var hasAudioCfg bool + if params != nil && params.AudioConfig != nil { + if ac, ok := params.AudioConfig.(mpeg4audio.AudioSpecificConfig); ok { + audioCfg = ac + hasAudioCfg = true + } + } + if !hasAudioCfg { + audioCfg = mpeg4audio.AudioSpecificConfig{Type: 2, SampleRate: 48000, ChannelCount: 2} + hasAudioCfg = true + } + + // 如果已经有 SPS/PPS,直接初始化 + if params != nil && params.VideoSPS != nil && params.VideoPPS != nil && hasAudioCfg { + if err := w.WriteInitSegment(params.VideoSPS, params.VideoPPS, audioCfg); err == nil { + w.initialized = true + return nil + } else { + return err + } + } + + // 保存参数,稍后在 Stream 中初始化 + w.codecParams = params + + w.initialized = true + return nil +} + +func (w *FMP4Muxer) Stream(videoCh <-chan VideoSample, audioCh <-chan AudioSample) error { + w.mu.Lock() + if !w.initialized { + w.mu.Unlock() + return fmt.Errorf("muxer not initialized") + } + w.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + wg.Add(1) + + // 使用现有的 WriteMixedBatch 逻辑进行批量处理 + go func() { + defer wg.Done() + defer w.logger.Info("FMP4 muxed aggregator ended") + + // 批量处理逻辑 + videoBatch := make([]VideoSampleInput, 0, 32) + audioBatch := make([]AudioSampleInput, 0, 32) + initDone := false + + ticker := time.NewTicker(150 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case sample, ok := <-videoCh: + if !ok { + return + } + + // 如果还没有初始化,尝试从第一个视频帧中提取 SPS/PPS + if !initDone && w.codecParams != nil { + w.logger.Info("Attempting to initialize from video frame", "data_len", len(sample.Data), "first_bytes", sample.Data[:min(len(sample.Data), 10)]) + if err := w.initializeFromVideoFrame(sample.Data); err == nil { + initDone = true + w.logger.Info("FMP4 writer initialized from video frame") + } else { + w.logger.Info("Failed to initialize from video frame", "error", err) + } + } + + videoBatch = append(videoBatch, VideoSampleInput{ + Data: sample.Data, + PTS: sample.PTS, + IsKey: sample.IsKeyFrame, + }) + case sample, ok := <-audioCh: + if !ok { + return + } + audioBatch = append(audioBatch, AudioSampleInput{ + Data: sample.Data, + PTS: sample.PTS, + }) + case <-ticker.C: + // 当有数据时发送批次 + if len(videoBatch) == 0 && len(audioBatch) == 0 { + continue + } + if err := w.WriteMixedBatch(videoBatch, audioBatch); err != nil { + w.logger.Error("Failed to write mixed batch", "error", err) + cancel() + return + } + videoBatch = videoBatch[:0] + audioBatch = audioBatch[:0] + } + } + }() + + wg.Wait() + return nil +} + +// initializeFromVideoFrame tries to extract SPS/PPS from video frame and initialize the writer +func (w *FMP4Muxer) initializeFromVideoFrame(data []byte) error { + if w.codecParams == nil { + w.codecParams = &CodecParams{} + } + + // 音频配置缺失时,使用默认的 AAC LC 配置 + var audioCfg mpeg4audio.AudioSpecificConfig + if w.codecParams.AudioConfig != nil { + if ac, ok := w.codecParams.AudioConfig.(mpeg4audio.AudioSpecificConfig); ok { + audioCfg = ac + } else { + audioCfg = mpeg4audio.AudioSpecificConfig{Type: 2, SampleRate: 48000, ChannelCount: 2} + } + } else { + audioCfg = mpeg4audio.AudioSpecificConfig{Type: 2, SampleRate: 48000, ChannelCount: 2} + } + + // Try to extract SPS/PPS from the video frame + sps, pps := w.extractSpsPpsFromFrame(data) + + // If we have both SPS and PPS, initialize the writer + if len(sps) > 0 && len(pps) > 0 { + return w.WriteInitSegment(sps, pps, audioCfg) + } + + return fmt.Errorf("could not extract SPS/PPS from video frame") +} + +// extractSpsPpsFromFrame extracts SPS/PPS from a video frame +func (w *FMP4Muxer) extractSpsPpsFromFrame(data []byte) ([]byte, []byte) { + var sps, pps []byte + + w.logger.Info("Extracting SPS/PPS from frame", "data_len", len(data), "first_bytes", data[:min(len(data), 10)]) + + // Try to parse as Annex-B format first (most common) + if len(data) > 0 && data[0] == 0x00 { + w.logger.Info("Detected Annex-B format") + // Annex-B format - use the h264 package to parse + var annexB h264.AnnexB + err := annexB.Unmarshal(data) + if err == nil { + w.logger.Info("Successfully parsed Annex-B", "nalu_count", len(annexB)) + for _, nalu := range annexB { + naluType := h264.NALUType(nalu[0] & 0x1F) + w.logger.Info("Found NALU", "type", naluType, "size", len(nalu)) + switch naluType { + case h264.NALUTypeSPS: + sps = nalu + w.logger.Info("Found SPS", "size", len(sps)) + case h264.NALUTypePPS: + pps = nalu + w.logger.Info("Found PPS", "size", len(pps)) + } + } + } else { + w.logger.Warn("Failed to parse Annex-B", "error", err) + } + } else if len(data) > 0 && data[0] == 0x01 { + // avcC format + w.logger.Info("Detected avcC format") + if ps, pp, ok := ParseAvccForSpsPps(data); ok { + sps, pps = ps, pp + w.logger.Info("Extracted SPS/PPS from avcC", "sps_size", len(sps), "pps_size", len(pps)) + } else { + w.logger.Warn("Failed to parse avcC format") + } + } else { + w.logger.Warn("Unknown format", "first_byte", data[0]) + } + + w.logger.Info("SPS/PPS extraction result", "sps_size", len(sps), "pps_size", len(pps)) + return sps, pps +} + +// ParseAvccForSpsPps extracts SPS/PPS from avcC box payload +func ParseAvccForSpsPps(avcc []byte) (sps, pps []byte, ok bool) { + if len(avcc) < 7 || avcc[0] != 0x01 { + return nil, nil, false + } + // avcC layout: version(1)=1, profile(1), compatibility(1), level(1), lengthSizeMinusOne(2 bits), reserved(3 bits), numOfSPS(3 bits), then SPS (2 bytes len + data)... then numOfPPS(1), PPS... + i := 5 + if i >= len(avcc) { + return nil, nil, false + } + // Extract numOfSPS from the lower 3 bits + numSps := int(avcc[i] & 0x07) + i++ + for n := 0; n < numSps && i+2 <= len(avcc); n++ { + l := int(avcc[i])<<8 | int(avcc[i+1]) + i += 2 + if i+l > len(avcc) { + return nil, nil, false + } + if l > 0 && sps == nil { + sps = append([]byte{}, avcc[i:i+l]...) + } + i += l + } + // Check if we have enough data for PPS + if i >= len(avcc) { + // No PPS data available + return sps, nil, false + } + + numPps := int(avcc[i]) + i++ + for n := 0; n < numPps && i+2 <= len(avcc); n++ { + l := int(avcc[i])<<8 | int(avcc[i+1]) + i += 2 + if i+l > len(avcc) { + return sps, pps, sps != nil && pps != nil + } + if l > 0 && pps == nil { + pps = append([]byte{}, avcc[i:i+l]...) + } + i += l + } + + // Only return ok=true if we have both SPS and PPS + return sps, pps, sps != nil && pps != nil +} diff --git a/packages/cli/internal/device_connect/transport/stream/fmp4_muxer_test.go b/packages/cli/internal/device_connect/transport/stream/fmp4_muxer_test.go new file mode 100644 index 00000000..eac5637d --- /dev/null +++ b/packages/cli/internal/device_connect/transport/stream/fmp4_muxer_test.go @@ -0,0 +1,223 @@ +package stream + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestParseAvccForSpsPps tests the parseAvccForSpsPps function with various inputs +func TestParseAvccForSpsPps(t *testing.T) { + tests := []struct { + name string + avcc []byte + expectSps []byte + expectPps []byte + expectOk bool + description string + }{ + { + name: "valid_avcc_with_sps_and_pps", + avcc: []byte{ + 0x01, // version + 0x42, // profile + 0x00, // compatibility + 0x28, // level + 0xFE, // lengthSizeMinusOne (11111110) + 0xE1, // numOfSPS (11100001 = 1 SPS) + 0x00, 0x19, // SPS length (25 bytes) + // SPS data (25 bytes) - NAL unit type 7 (SPS) + 0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02, + 0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04, + 0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, + 0x20, + 0x01, // numOfPPS (1 PPS) + 0x00, 0x04, // PPS length (4 bytes) + // PPS data (4 bytes) - NAL unit type 8 (PPS) + 0x68, 0xce, 0x38, 0x80, + }, + expectSps: []byte{ + 0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02, + 0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04, + 0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, + 0x20, + }, + expectPps: []byte{0x68, 0xce, 0x38, 0x80}, + expectOk: true, + description: "Valid avcC with one SPS and one PPS", + }, + { + name: "valid_avcc_multiple_sps", + avcc: []byte{ + 0x01, // version + 0x42, // profile + 0x00, // compatibility + 0x28, // level + 0xFE, // lengthSizeMinusOne + 0xE2, // numOfSPS (11100010 = 2 SPS) + 0x00, 0x0A, // SPS1 length (10 bytes) + 0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02, 0x27, 0xe5, // SPS1 + 0x00, 0x0B, // SPS2 length (11 bytes) + 0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02, 0x27, 0xe5, 0x84, // SPS2 + 0x01, // numOfPPS + 0x00, 0x04, // PPS length + 0x68, 0xce, 0x38, 0x80, // PPS + }, + expectSps: []byte{0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02, 0x27, 0xe5}, // First SPS + expectPps: []byte{0x68, 0xce, 0x38, 0x80}, + expectOk: true, + description: "Valid avcC with multiple SPS (should return first one)", + }, + { + name: "invalid_version", + avcc: []byte{ + 0x02, // invalid version (should be 1) + 0x42, 0x00, 0x28, 0xFE, 0xE1, + 0x00, 0x04, 0x67, 0x42, 0xc0, 0x28, + 0x01, 0x00, 0x04, 0x68, 0xce, 0x38, 0x80, + }, + expectSps: nil, + expectPps: nil, + expectOk: false, + description: "Invalid version (should be 1)", + }, + { + name: "too_short_avcc", + avcc: []byte{0x01, 0x42, 0x00}, // Too short + expectSps: nil, + expectPps: nil, + expectOk: false, + description: "Too short avcC data", + }, + { + name: "no_sps", + avcc: []byte{ + 0x01, // version + 0x42, // profile + 0x00, // compatibility + 0x28, // level + 0xFE, // lengthSizeMinusOne + 0x00, // numOfSPS (0 SPS) + 0x01, // numOfPPS + 0x00, 0x04, // PPS length + 0x68, 0xce, 0x38, 0x80, // PPS + }, + expectSps: nil, + expectPps: []byte{0x68, 0xce, 0x38, 0x80}, // PPS should be extracted + expectOk: false, // Should fail because no SPS (we need both SPS and PPS) + description: "No SPS present (only PPS)", + }, + { + name: "no_pps", + avcc: []byte{ + 0x01, // version + 0x42, // profile + 0x00, // compatibility + 0x28, // level + 0xFE, // lengthSizeMinusOne + 0xE1, // numOfSPS (1 SPS) + 0x00, 0x04, // SPS length + 0x67, 0x42, 0xc0, 0x28, // SPS + 0x00, // numOfPPS (0 PPS) + }, + expectSps: []byte{0x67, 0x42, 0xc0, 0x28}, + expectPps: nil, + expectOk: false, // Should fail because no PPS + description: "No PPS present", + }, + { + name: "malformed_sps_length", + avcc: []byte{ + 0x01, // version + 0x42, // profile + 0x00, // compatibility + 0x28, // level + 0xFE, // lengthSizeMinusOne + 0xE1, // numOfSPS (1 SPS) + 0xFF, 0xFF, // Invalid SPS length (too large) + 0x67, 0x42, // Partial SPS data + }, + expectSps: nil, + expectPps: nil, + expectOk: false, + description: "Malformed SPS length", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sps, pps, ok := ParseAvccForSpsPps(tt.avcc) + + assert.Equal(t, tt.expectOk, ok, "Expected ok=%v, got ok=%v. %s", tt.expectOk, ok, tt.description) + assert.Equal(t, tt.expectSps, sps, "SPS mismatch. %s", tt.description) + assert.Equal(t, tt.expectPps, pps, "PPS mismatch. %s", tt.description) + + if tt.expectOk { + require.NotNil(t, sps, "SPS should not be nil when ok=true") + require.NotNil(t, pps, "PPS should not be nil when ok=true") + require.Greater(t, len(sps), 0, "SPS should not be empty") + require.Greater(t, len(pps), 0, "PPS should not be empty") + + // Verify NAL unit types + assert.Equal(t, byte(7), sps[0]&0x1F, "SPS should have NAL unit type 7") + assert.Equal(t, byte(8), pps[0]&0x1F, "PPS should have NAL unit type 8") + } + }) + } +} + +// TestParseAvccForSpsPpsRealData tests with real-world avcC data +func TestParseAvccForSpsPpsRealData(t *testing.T) { + // This is a real avcC configuration from a test file + realAvcc := []byte{ + 0x01, 0x42, 0xC0, 0x1E, 0xFF, 0xE1, 0x00, 0x17, 0x67, 0x42, 0xC0, 0x1E, + 0xAB, 0x40, 0xF0, 0x28, 0x0F, 0x68, 0x40, 0x00, 0x00, 0x03, 0x00, 0x40, + 0x00, 0x00, 0x07, 0xA3, 0xC7, 0x08, 0x01, 0x00, 0x04, 0x68, 0xCE, 0x3C, + 0x80, + } + + t.Logf("Real avcC data: %x", realAvcc) + t.Logf("avcC length: %d", len(realAvcc)) + t.Logf("Version: 0x%02x", realAvcc[0]) + t.Logf("Profile: 0x%02x", realAvcc[1]) + t.Logf("Compatibility: 0x%02x", realAvcc[2]) + t.Logf("Level: 0x%02x", realAvcc[3]) + t.Logf("LengthSizeMinusOne: 0x%02x", realAvcc[4]) + t.Logf("numOfSPS: 0x%02x (%d)", realAvcc[5], realAvcc[5]&0x1F) + + sps, pps, ok := ParseAvccForSpsPps(realAvcc) + + t.Logf("Parse result: ok=%v, sps_len=%d, pps_len=%d", ok, len(sps), len(pps)) + + if !ok { + t.Log("Failed to parse real avcC data, but this might be expected") + return + } + + assert.NotNil(t, sps, "SPS should not be nil") + assert.NotNil(t, pps, "PPS should not be nil") + assert.Greater(t, len(sps), 0, "SPS should not be empty") + assert.Greater(t, len(pps), 0, "PPS should not be empty") + + // Verify NAL unit types + assert.Equal(t, byte(7), sps[0]&0x1F, "SPS should have NAL unit type 7") + assert.Equal(t, byte(8), pps[0]&0x1F, "PPS should have NAL unit type 8") + + t.Logf("Extracted SPS: %d bytes, PPS: %d bytes", len(sps), len(pps)) +} + +// BenchmarkParseAvccForSpsPps benchmarks the parsing function +func BenchmarkParseAvccForSpsPps(b *testing.B) { + avcc := []byte{ + 0x01, 0x42, 0x00, 0x28, 0xFE, 0xE1, 0x00, 0x19, 0x67, 0x42, 0xc0, 0x28, + 0xd9, 0x00, 0x78, 0x02, 0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04, + 0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, 0x20, 0x01, 0x00, 0x04, + 0x68, 0xce, 0x38, 0x80, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = ParseAvccForSpsPps(avcc) + } +} diff --git a/packages/cli/internal/device_connect/transport/stream/fmp4_writer.go b/packages/cli/internal/device_connect/transport/stream/fmp4_writer.go new file mode 100644 index 00000000..411bbdfc --- /dev/null +++ b/packages/cli/internal/device_connect/transport/stream/fmp4_writer.go @@ -0,0 +1,668 @@ +package stream + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "sync" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/h264" + "github.com/bluenviron/mediacommon/v2/pkg/codecs/mpeg4audio" + "github.com/bluenviron/mediacommon/v2/pkg/formats/fmp4" + "github.com/bluenviron/mediacommon/v2/pkg/formats/fmp4/seekablebuffer" + "github.com/bluenviron/mediacommon/v2/pkg/formats/mp4" +) + +// scaleTimestampToTimescale converts a timestamp expressed in microseconds +// into the given MP4 track timescale units. +func scaleTimestampToTimescale(timestampUs int64, timeScale uint32) int64 { + if timestampUs <= 0 { + return 0 + } + // Prevent overflow by doing 64-bit math + return (timestampUs * int64(timeScale)) / 1_000_000 +} + +// stripADTSHeader removes the ADTS header if present and returns the raw AAC payload. +// If no ADTS header is detected, returns the original data. +func stripADTSHeader(data []byte) []byte { + if len(data) < 7 { + return data + } + // ADTS syncword 12 bits: 0xFFF + if data[0] == 0xFF && (data[1]&0xF0) == 0xF0 { + // protection_absent is the last bit of byte 1 + headerLen := 7 + if (data[1] & 0x01) == 0 { // CRC present => 2 extra bytes + headerLen = 9 + } + if len(data) > headerLen { + return data[headerLen:] + } + } + return data +} + +// FMP4StreamWriter is an fMP4 writer for HTTP streaming. +// It is based on gohlslib but simplified for direct streaming. +type FMP4StreamWriter struct { + writer io.Writer + logger *slog.Logger + videoTrack *fmp4Track + audioTrack *fmp4Track + initSent bool + mu sync.Mutex // protects concurrent writes + flusher http.Flusher + closed bool + sequenceNumber uint32 +} + +type fmp4Track struct { + id uint32 + codec mp4.Codec + clockRate uint32 + timeScale uint32 + lastDTS int64 // in track timescale units + firstDTS int64 // in track timescale units + sampleNum uint32 +} + +// NewFMP4StreamWriter creates a new fMP4 stream writer +func NewFMP4StreamWriter(writer io.Writer, logger *slog.Logger, videoWidth, videoHeight uint32) *FMP4StreamWriter { + w := &FMP4StreamWriter{ + writer: writer, + logger: logger, + videoTrack: &fmp4Track{ + id: 1, + clockRate: 90000, + timeScale: 90000, + }, + audioTrack: &fmp4Track{ + id: 2, + clockRate: 48000, + timeScale: 48000, + }, + sequenceNumber: 1, + } + // Capture HTTP flusher if available + if f, ok := writer.(http.Flusher); ok { + w.flusher = f + } + return w +} + +// WriteInitSegment writes the fMP4 initialization segment +func (w *FMP4StreamWriter) WriteInitSegment(videoSPS, videoPPS []byte, audioConfig mpeg4audio.AudioSpecificConfig) error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.initSent { + return nil + } + + // Create video codec + videoCodec := &mp4.CodecH264{ + SPS: videoSPS, + PPS: videoPPS, + } + w.videoTrack.codec = videoCodec + + // Create audio codec + audioCodec := &mp4.CodecMPEG4Audio{ + Config: audioConfig, + } + w.audioTrack.codec = audioCodec + + // Create fMP4 init segment + init := &fmp4.Init{ + Tracks: []*fmp4.InitTrack{ + { + ID: int(w.videoTrack.id), + TimeScale: w.videoTrack.timeScale, + Codec: videoCodec, + }, + { + ID: int(w.audioTrack.id), + TimeScale: w.audioTrack.timeScale, + Codec: audioCodec, + }, + }, + } + + // Serialize init segment + var buf seekablebuffer.Buffer + err := init.Marshal(&buf) + if err != nil { + return fmt.Errorf("failed to marshal init segment: %w", err) + } + initBytes := buf.Bytes() + + // Write init segment + if _, err := w.writer.Write(initBytes); err != nil { + return fmt.Errorf("failed to write init segment: %w", err) + } + + w.initSent = true + w.logger.Info("fMP4 init segment written", "size", len(initBytes)) + if w.flusher != nil { + w.flusher.Flush() + } + return nil +} + +// WriteVideoFrame writes a video frame +func (w *FMP4StreamWriter) WriteVideoFrame(data []byte, pts int64, isKeyFrame bool) error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.closed { + return fmt.Errorf("writer closed") + } + + if !w.initSent { + return fmt.Errorf("init segment not written yet") + } + + if len(data) == 0 { + w.logger.Debug("Skipping empty video frame", "pts", pts) + return nil + } + + // Convert Annex-B (start codes) to AVCC (length-prefixed) for MP4 compliance + avcData, err := h264.ConvertAnnexBToAVC(data) + if err != nil { + return fmt.Errorf("failed to convert AnnexB to AVCC: %w", err) + } + if len(avcData) == 0 { + w.logger.Debug("Skipping empty converted video frame", "pts", pts) + return nil + } + + // For keyframes, prepend SPS/PPS NAL units to improve decoder robustness + if isKeyFrame { + if c, ok := w.videoTrack.codec.(*mp4.CodecH264); ok && len(c.SPS) > 0 && len(c.PPS) > 0 { + avcData = h264.PrependParameterSetsAVCC(avcData, c.SPS, c.PPS) + } + } + + // Create fMP4 sample + sample := &fmp4.Sample{ + IsNonSyncSample: !isKeyFrame, + Payload: avcData, + } + + // Scale PTS (microseconds) into track timescale (e.g., 90000) + dts := scaleTimestampToTimescale(pts, w.videoTrack.timeScale) + if w.videoTrack.firstDTS == 0 { + w.videoTrack.firstDTS = dts + } + + // Compute sample duration; if first frame, set a reasonable default (~30fps) + if w.videoTrack.lastDTS != 0 { + duration := dts - w.videoTrack.lastDTS + if duration > 0 { + sample.Duration = uint32(duration) + } + } + if sample.Duration == 0 { + // default to 30fps when duration not known yet + sample.Duration = uint32(w.videoTrack.clockRate / 30) + } + + // Create media part with mfhd/tfdt via Part API (sequence number set later by Marshal) + segment := &fmp4.Part{ + Tracks: []*fmp4.PartTrack{ + { + ID: int(w.videoTrack.id), + BaseTime: uint64(w.videoTrack.firstDTS), // tfdt for video track + Samples: []*fmp4.Sample{sample}, + }, + }, + SequenceNumber: w.sequenceNumber, + } + + // Serialize media part + var buf seekablebuffer.Buffer + err = segment.Marshal(&buf) + if err != nil { + return fmt.Errorf("failed to marshal video segment: %w", err) + } + segmentBytes := buf.Bytes() + + // Write media part + if _, err := w.writer.Write(segmentBytes); err != nil { + w.logger.Error("Failed to write video segment", "error", err, "size", len(segmentBytes)) + return fmt.Errorf("failed to write video segment: %w", err) + } + + w.videoTrack.lastDTS = dts + w.videoTrack.sampleNum++ + w.sequenceNumber++ + + w.logger.Debug("Video frame written", "pts", pts, "dts", dts, "isKeyFrame", isKeyFrame, "size", len(segmentBytes)) + if w.flusher != nil { + w.flusher.Flush() + } + return nil +} + +// WriteAudioFrame writes an audio frame +func (w *FMP4StreamWriter) WriteAudioFrame(data []byte, pts int64) error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.closed { + return fmt.Errorf("writer closed") + } + + if !w.initSent { + return fmt.Errorf("init segment not written yet") + } + + if len(data) == 0 { + w.logger.Debug("Skipping empty audio frame", "pts", pts) + return nil + } + + // Some encoders may output ADTS frames; MP4 samples must be raw AAC + data = stripADTSHeader(data) + + // Create fMP4 sample + sample := &fmp4.Sample{ + IsNonSyncSample: false, // audio samples are typically sync samples + Payload: data, + } + + // Compute DTS by converting microseconds to 48k timescale + dts := scaleTimestampToTimescale(pts, w.audioTrack.timeScale) + if w.audioTrack.firstDTS == 0 { + w.audioTrack.firstDTS = dts + } + + // Compute sample duration; AAC typically 1024 samples per frame + if w.audioTrack.lastDTS != 0 { + duration := dts - w.audioTrack.lastDTS + if duration > 0 { + sample.Duration = uint32(duration) + } + } + if sample.Duration == 0 { + sample.Duration = 1024 + } + + // Create media part with normalized base time + baseAudio := int64(0) + if w.audioTrack.firstDTS != 0 { + baseAudio = dts - w.audioTrack.firstDTS + if baseAudio < 0 { + baseAudio = 0 + } + } + segment := &fmp4.Part{ + Tracks: []*fmp4.PartTrack{ + { + ID: int(w.audioTrack.id), + BaseTime: uint64(baseAudio), + Samples: []*fmp4.Sample{ + sample, + }, + }, + }, + SequenceNumber: w.sequenceNumber, + } + + // Serialize media part + var buf seekablebuffer.Buffer + err := segment.Marshal(&buf) + if err != nil { + return fmt.Errorf("failed to marshal audio segment: %w", err) + } + segmentBytes := buf.Bytes() + + // Write media part + if _, err := w.writer.Write(segmentBytes); err != nil { + w.logger.Error("Failed to write audio segment", "error", err, "size", len(segmentBytes)) + return fmt.Errorf("failed to write audio segment: %w", err) + } + + w.audioTrack.lastDTS = dts + w.audioTrack.sampleNum++ + w.sequenceNumber++ + + w.logger.Debug("Audio frame written", "pts", pts, "dts", dts, "size", len(segmentBytes)) + if w.flusher != nil { + w.flusher.Flush() + } + return nil +} + +// WriteMixedFrame writes mixed video and audio frames in one part +func (w *FMP4StreamWriter) WriteMixedFrame(videoData []byte, audioData []byte, videoPTS, audioPTS int64, isKeyFrame bool) error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.closed { + return fmt.Errorf("writer closed") + } + + if !w.initSent { + return fmt.Errorf("init segment not written yet") + } + + // Convert H.264 Annex-B to AVCC + if len(videoData) == 0 { + return nil + } + avcData, err := h264.ConvertAnnexBToAVC(videoData) + if err != nil { + return fmt.Errorf("failed to convert AnnexB to AVCC: %w", err) + } + if len(avcData) == 0 { + return nil + } + + // Strip ADTS headers from AAC (MP4 requires raw AAC) + if len(audioData) > 0 { + audioData = stripADTSHeader(audioData) + } + + // For keyframes, prepend SPS/PPS parameter sets + if isKeyFrame { + if c, ok := w.videoTrack.codec.(*mp4.CodecH264); ok && len(c.SPS) > 0 && len(c.PPS) > 0 { + avcData = h264.PrependParameterSetsAVCC(avcData, c.SPS, c.PPS) + } + } + // Create video sample + videoSample := &fmp4.Sample{ + IsNonSyncSample: !isKeyFrame, + Payload: avcData, + } + + // Create audio sample + audioSample := &fmp4.Sample{ + IsNonSyncSample: false, + Payload: audioData, + } + + // Compute DTS (scale microseconds into track timescales) + videoDTS := scaleTimestampToTimescale(videoPTS, w.videoTrack.timeScale) + audioDTS := scaleTimestampToTimescale(audioPTS, w.audioTrack.timeScale) + + if w.videoTrack.firstDTS == 0 { + w.videoTrack.firstDTS = videoDTS + } + if w.audioTrack.firstDTS == 0 { + w.audioTrack.firstDTS = audioDTS + } + + // Compute sample duration + if w.videoTrack.lastDTS != 0 { + duration := videoDTS - w.videoTrack.lastDTS + if duration > 0 { + videoSample.Duration = uint32(duration) + } + } + if videoSample.Duration == 0 { + videoSample.Duration = uint32(w.videoTrack.clockRate / 30) + } + + if w.audioTrack.lastDTS != 0 { + duration := audioDTS - w.audioTrack.lastDTS + if duration > 0 { + audioSample.Duration = uint32(duration) + } + } + if audioSample.Duration == 0 { + audioSample.Duration = 1024 + } + + // Create mixed media part with base times and sequence number + vBase := int64(0) + if w.videoTrack.firstDTS != 0 { + vBase = videoDTS - w.videoTrack.firstDTS + if vBase < 0 { + vBase = 0 + } + } + aBase := int64(0) + if w.audioTrack.firstDTS != 0 { + aBase = audioDTS - w.audioTrack.firstDTS + if aBase < 0 { + aBase = 0 + } + } + segment := &fmp4.Part{ + Tracks: []*fmp4.PartTrack{ + {ID: int(w.videoTrack.id), BaseTime: uint64(vBase), Samples: []*fmp4.Sample{videoSample}}, + {ID: int(w.audioTrack.id), BaseTime: uint64(aBase), Samples: []*fmp4.Sample{audioSample}}, + }, + SequenceNumber: w.sequenceNumber, + } + + // Serialize media part + var buf seekablebuffer.Buffer + err = segment.Marshal(&buf) + if err != nil { + return fmt.Errorf("failed to marshal mixed segment: %w", err) + } + segmentBytes := buf.Bytes() + + // Write media part + if _, err := w.writer.Write(segmentBytes); err != nil { + return fmt.Errorf("failed to write mixed segment: %w", err) + } + + w.videoTrack.lastDTS = videoDTS + w.audioTrack.lastDTS = audioDTS + w.videoTrack.sampleNum++ + w.audioTrack.sampleNum++ + w.sequenceNumber++ + + if w.flusher != nil { + w.flusher.Flush() + } + + w.logger.Debug("Mixed frame written", + "videoPTS", videoPTS, "audioPTS", audioPTS, + "isKeyFrame", isKeyFrame, "size", len(segmentBytes)) + return nil +} + +// VideoSampleInput represents one video sample to mux +type VideoSampleInput struct { + Data []byte + PTS int64 + IsKey bool +} + +// AudioSampleInput represents one audio sample to mux +type AudioSampleInput struct { + Data []byte + PTS int64 +} + +// WriteMixedBatch writes multiple video and audio samples in a single fragment +func (w *FMP4StreamWriter) WriteMixedBatch(videoSamples []VideoSampleInput, audioSamples []AudioSampleInput) error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.closed { + return fmt.Errorf("writer closed") + } + if !w.initSent { + return fmt.Errorf("init segment not written yet") + } + if len(videoSamples) == 0 && len(audioSamples) == 0 { + return nil + } + + // Sort by PTS to ensure monotonic order + sort.Slice(videoSamples, func(i, j int) bool { return videoSamples[i].PTS < videoSamples[j].PTS }) + sort.Slice(audioSamples, func(i, j int) bool { return audioSamples[i].PTS < audioSamples[j].PTS }) + + // Build track samples + var vSamples []*fmp4.Sample + var aSamples []*fmp4.Sample + + // Track DTS arrays for duration calculation + var vDTS []int64 + var aDTS []int64 + + // Video: convert to AVCC and scale PTS + for _, vs := range videoSamples { + if len(vs.Data) == 0 { + continue + } + avcData, err := h264.ConvertAnnexBToAVC(vs.Data) + if err != nil || len(avcData) == 0 { + continue + } + dts := scaleTimestampToTimescale(vs.PTS, w.videoTrack.timeScale) + vDTS = append(vDTS, dts) + vSamples = append(vSamples, &fmp4.Sample{ + IsNonSyncSample: !vs.IsKey, + Payload: avcData, + }) + } + + // Audio: strip ADTS, scale PTS + for _, as := range audioSamples { + if len(as.Data) == 0 { + continue + } + raw := stripADTSHeader(as.Data) + if len(raw) == 0 { + continue + } + dts := scaleTimestampToTimescale(as.PTS, w.audioTrack.timeScale) + aDTS = append(aDTS, dts) + aSamples = append(aSamples, &fmp4.Sample{ + IsNonSyncSample: false, + Payload: raw, + }) + } + + // Assign durations + if len(vSamples) > 0 { + prev := w.videoTrack.lastDTS + for i := 0; i < len(vSamples); i++ { + cur := vDTS[i] + var next int64 + if i+1 < len(vDTS) { + next = vDTS[i+1] + } else { + next = cur + } + dur := int64(0) + if prev != 0 { + dur = cur - prev + } else if next > cur { + dur = next - cur + } + if dur <= 0 { + dur = int64(w.videoTrack.clockRate / 30) + } + vSamples[i].Duration = uint32(dur) + prev = cur + } + w.videoTrack.lastDTS = vDTS[len(vDTS)-1] + w.videoTrack.sampleNum += uint32(len(vSamples)) + if w.videoTrack.firstDTS == 0 && len(vDTS) > 0 { + w.videoTrack.firstDTS = vDTS[0] + } + } + + if len(aSamples) > 0 { + prev := w.audioTrack.lastDTS + for i := 0; i < len(aSamples); i++ { + cur := aDTS[i] + var next int64 + if i+1 < len(aDTS) { + next = aDTS[i+1] + } else { + next = cur + } + dur := int64(0) + if prev != 0 { + dur = cur - prev + } else if next > cur { + dur = next - cur + } + if dur <= 0 { + dur = 1024 + } + aSamples[i].Duration = uint32(dur) + prev = cur + } + w.audioTrack.lastDTS = aDTS[len(aDTS)-1] + w.audioTrack.sampleNum += uint32(len(aSamples)) + if w.audioTrack.firstDTS == 0 && len(aDTS) > 0 { + w.audioTrack.firstDTS = aDTS[0] + } + } + + // Build part with both tracks + part := &fmp4.Part{SequenceNumber: w.sequenceNumber, Tracks: []*fmp4.PartTrack{}} + if len(vSamples) > 0 { + // Set BaseTime to first video DTS relative to stream start + baseTime := uint64(0) + if w.videoTrack.firstDTS > 0 && len(vDTS) > 0 { + relativeTime := vDTS[0] - w.videoTrack.firstDTS + if relativeTime >= 0 { + baseTime = uint64(relativeTime) + } + } + part.Tracks = append(part.Tracks, &fmp4.PartTrack{ + ID: int(w.videoTrack.id), + BaseTime: baseTime, + Samples: vSamples, + }) + } + if len(aSamples) > 0 { + // Set BaseTime to first audio DTS relative to stream start + baseTime := uint64(0) + if w.audioTrack.firstDTS > 0 && len(aDTS) > 0 { + relativeTime := aDTS[0] - w.audioTrack.firstDTS + if relativeTime >= 0 { + baseTime = uint64(relativeTime) + } + } + part.Tracks = append(part.Tracks, &fmp4.PartTrack{ + ID: int(w.audioTrack.id), + BaseTime: baseTime, + Samples: aSamples, + }) + } + + var buf seekablebuffer.Buffer + if err := part.Marshal(&buf); err != nil { + return fmt.Errorf("failed to marshal mixed batch: %w", err) + } + bytes := buf.Bytes() + if _, err := w.writer.Write(bytes); err != nil { + return fmt.Errorf("failed to write mixed batch: %w", err) + } + if w.flusher != nil { + w.flusher.Flush() + } + w.logger.Debug("Mixed batch written", "video", len(vSamples), "audio", len(aSamples), "size", len(bytes)) + // advance global sequence number for next fragment + w.sequenceNumber++ + return nil +} + +// Close closes the writer +func (w *FMP4StreamWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return nil + } + w.closed = true + w.logger.Info("FMP4 stream writer closed", + "videoSamples", w.videoTrack.sampleNum, + "audioSamples", w.audioTrack.sampleNum) + return nil +} diff --git a/packages/cli/internal/device_connect/transport/stream/fmp4_writer_test.go b/packages/cli/internal/device_connect/transport/stream/fmp4_writer_test.go new file mode 100644 index 00000000..7319ba38 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/stream/fmp4_writer_test.go @@ -0,0 +1,323 @@ +package stream + +import ( + "bytes" + "log/slog" + "os" + "os/exec" + "testing" + "time" + + "github.com/bluenviron/mediacommon/v2/pkg/codecs/mpeg4audio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 测试用的 H.264 数据(简化的测试数据) +var testSPS = []byte{ + 0x67, 0x42, 0xc0, 0x28, 0xd9, 0x00, 0x78, 0x02, + 0x27, 0xe5, 0x84, 0x00, 0x00, 0x03, 0x00, 0x04, + 0x00, 0x00, 0x03, 0x00, 0xf0, 0x3c, 0x60, 0xc9, + 0x20, +} + +var testPPS = []byte{0x68, 0xce, 0x38, 0x80} + +var testIDR = []byte{0x65, 0x88, 0x84, 0x00, 0x10} + +var testPFrame = []byte{0x41, 0x9a, 0x24, 0x8c, 0x09} + +// 测试用的 AAC 数据 +var testAACFrame = []byte{ + 0x12, 0x10, 0x56, 0xe5, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +} + +func TestFMP4StreamWriter_WriteInitSegment(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + + writer := NewFMP4StreamWriter(&buf, logger, 1920, 1080) + + // 测试音频配置 + audioConfig := mpeg4audio.AudioSpecificConfig{ + Type: 2, // AAC + SampleRate: 48000, + ChannelCount: 2, + } + + err := writer.WriteInitSegment(testSPS, testPPS, audioConfig) + require.NoError(t, err) + + // 验证初始化段不为空 + assert.Greater(t, buf.Len(), 0, "Init segment should not be empty") + + // 验证可以重复调用而不出错 + err = writer.WriteInitSegment(testSPS, testPPS, audioConfig) + require.NoError(t, err) +} + +func TestFMP4StreamWriter_WriteVideoFrame(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + + writer := NewFMP4StreamWriter(&buf, logger, 1920, 1080) + + // 先写入初始化段 + audioConfig := mpeg4audio.AudioSpecificConfig{ + Type: 2, + SampleRate: 48000, + ChannelCount: 2, + } + err := writer.WriteInitSegment(testSPS, testPPS, audioConfig) + require.NoError(t, err) + + // 写入 IDR 帧 + h264Data := append(append(testSPS, testPPS...), testIDR...) + err = writer.WriteVideoFrame(h264Data, 0, true) + require.NoError(t, err) + + // 写入 P 帧 + err = writer.WriteVideoFrame(testPFrame, 3000, false) + require.NoError(t, err) + + // 验证有数据写入 + assert.Greater(t, buf.Len(), 0, "Should have written video frames") +} + +func TestFMP4StreamWriter_WriteAudioFrame(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + + writer := NewFMP4StreamWriter(&buf, logger, 1920, 1080) + + // 先写入初始化段 + audioConfig := mpeg4audio.AudioSpecificConfig{ + Type: 2, + SampleRate: 48000, + ChannelCount: 2, + } + err := writer.WriteInitSegment(testSPS, testPPS, audioConfig) + require.NoError(t, err) + + // 写入音频帧 + err = writer.WriteAudioFrame(testAACFrame, 0) + require.NoError(t, err) + + // 验证有数据写入 + assert.Greater(t, buf.Len(), 0, "Should have written audio frames") +} + +func TestFMP4StreamWriter_WriteMixedFrame(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + + writer := NewFMP4StreamWriter(&buf, logger, 1920, 1080) + + // 先写入初始化段 + audioConfig := mpeg4audio.AudioSpecificConfig{ + Type: 2, + SampleRate: 48000, + ChannelCount: 2, + } + err := writer.WriteInitSegment(testSPS, testPPS, audioConfig) + require.NoError(t, err) + + // 写入混合帧 + videoData := append(append(testSPS, testPPS...), testIDR...) + err = writer.WriteMixedFrame(videoData, testAACFrame, 0, 0, true) + require.NoError(t, err) + + // 验证有数据写入 + assert.Greater(t, buf.Len(), 0, "Should have written mixed frames") +} + +func TestFMP4StreamWriter_ErrorHandling(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + + writer := NewFMP4StreamWriter(&buf, logger, 1920, 1080) + + // 测试在未写入初始化段时写入帧 + err := writer.WriteVideoFrame(testIDR, 0, true) + assert.Error(t, err, "Should error when writing frame before init segment") + + err = writer.WriteAudioFrame(testAACFrame, 0) + assert.Error(t, err, "Should error when writing audio before init segment") +} + +// 集成测试:验证生成的 fMP4 流可以被 ffprobe 正确解析 +func TestFMP4StreamWriter_FFProbeValidation(t *testing.T) { + // 跳过如果 ffprobe 不可用 + if !isFFProbeAvailable() { + t.Skip("ffprobe not available, skipping validation test") + } + + // 创建临时文件 + tmpFile, err := os.CreateTemp("", "test_fmp4_*.mp4") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + writer := NewFMP4StreamWriter(tmpFile, logger, 1920, 1080) + + // 写入初始化段 + audioConfig := mpeg4audio.AudioSpecificConfig{ + Type: 2, + SampleRate: 48000, + ChannelCount: 2, + } + err = writer.WriteInitSegment(testSPS, testPPS, audioConfig) + require.NoError(t, err) + + // 写入一些测试帧 + for i := 0; i < 5; i++ { + videoData := append(append(testSPS, testPPS...), testIDR...) + err = writer.WriteVideoFrame(videoData, int64(i*3000), i == 0) + require.NoError(t, err) + + err = writer.WriteAudioFrame(testAACFrame, int64(i*1024)) + require.NoError(t, err) + } + + err = writer.Close() + require.NoError(t, err) + + // 使用 ffprobe 验证文件 + err = tmpFile.Close() + require.NoError(t, err) + + cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", tmpFile.Name()) + output, err := cmd.Output() + require.NoError(t, err, "ffprobe should be able to parse the generated fMP4 file") + + // 验证输出包含预期的流信息 + outputStr := string(output) + assert.Contains(t, outputStr, "codec_name", "Should contain codec information") + assert.Contains(t, outputStr, "codec_type", "Should contain stream type information") +} + +// 性能测试:测试大量帧的写入性能 +func TestFMP4StreamWriter_Performance(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + + writer := NewFMP4StreamWriter(&buf, logger, 1920, 1080) + + // 写入初始化段 + audioConfig := mpeg4audio.AudioSpecificConfig{ + Type: 2, + SampleRate: 48000, + ChannelCount: 2, + } + err := writer.WriteInitSegment(testSPS, testPPS, audioConfig) + require.NoError(t, err) + + // 测试写入大量帧的性能 + frameCount := 1000 + start := time.Now() + + for i := 0; i < frameCount; i++ { + videoData := append(append(testSPS, testPPS...), testIDR...) + err = writer.WriteVideoFrame(videoData, int64(i*3000), i%30 == 0) // 每30帧一个关键帧 + require.NoError(t, err) + + err = writer.WriteAudioFrame(testAACFrame, int64(i*1024)) + require.NoError(t, err) + } + + duration := time.Since(start) + + t.Logf("Wrote %d frames in %v (%.2f frames/sec)", + frameCount, duration, float64(frameCount)/duration.Seconds()) + + // 验证性能合理(应该能在1秒内写入1000帧) + assert.Less(t, duration, time.Second, "Should write 1000 frames in less than 1 second") +} + +// 辅助函数:检查 ffprobe 是否可用 +func isFFProbeAvailable() bool { + cmd := exec.Command("ffprobe", "-version") + err := cmd.Run() + return err == nil +} + +// 是否可用 mp4dump(Bento4) +func isMp4DumpAvailable() bool { + cmd := exec.Command("mp4dump", "-version") + err := cmd.Run() + return err == nil +} + +// 使用 mp4dump 校验由 FMP4StreamWriter 生成的分片时间线(需要 mp4dump) +func Test_FMP4Writer_FragmentTimeline_WithMp4dump(t *testing.T) { + if !isMp4DumpAvailable() { + t.Skip("mp4dump not available, skipping") + } + + // 写入少量片段到临时文件 + tmpFile, err := os.CreateTemp("", "writer_frag_*.mp4") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn})) + w := NewFMP4StreamWriter(tmpFile, logger, 1200, 2664) + + audioCfg := mpeg4audio.AudioSpecificConfig{Type: 2, SampleRate: 48000, ChannelCount: 2} + require.NoError(t, w.WriteInitSegment(testSPS, testPPS, audioCfg)) + + // 写三段(每段一视频一音频) + // 以微秒时间轴:视频 33ms;音频 21.33ms + vpts := int64(0) + apts := int64(0) + for i := 0; i < 3; i++ { + v := append(append([]byte{}, testSPS...), append(testPPS, testIDR...)...) + require.NoError(t, w.WriteMixedFrame(v, testAACFrame, vpts, apts, true)) + vpts += 33_000 + apts += 21_333 + } + require.NoError(t, w.Close()) + + // 用 mp4dump 读取,检查 sequence number 与 tfdt 是否随片段推进 + // 注:当前实现可能尚未设置递增的 seq/tfdt,本测试可帮助回归 + cmd := exec.Command("mp4dump", "--verbosity", "3", tmpFile.Name()) + out, err := cmd.Output() + require.NoError(t, err) + s := string(out) + + // 统计 moof 个数 + require.Contains(t, s, "[moof]", "should contain moof") + + // 软断言:出现多个 sequence number 行与 tfdt 行 + // 具体递增性待后续实现完善(当前库可能默认 0) + // 这里仅做存在性检查,避免 CI 阻塞 + require.Contains(t, s, "sequence number =", "missing mfhd sequence number") + require.Contains(t, s, "base media decode time =", "missing tfdt base decode time") +} + +// 基准测试 +func BenchmarkFMP4StreamWriter_WriteFrame(b *testing.B) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + + writer := NewFMP4StreamWriter(&buf, logger, 1920, 1080) + + // 写入初始化段 + audioConfig := mpeg4audio.AudioSpecificConfig{ + Type: 2, + SampleRate: 48000, + ChannelCount: 2, + } + writer.WriteInitSegment(testSPS, testPPS, audioConfig) + + videoData := append(append(testSPS, testPPS...), testIDR...) + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + writer.WriteVideoFrame(videoData, int64(i*3000), i%30 == 0) + writer.WriteAudioFrame(testAACFrame, int64(i*1024)) + } +} diff --git a/packages/cli/internal/device_connect/transport/stream/manager.go b/packages/cli/internal/device_connect/transport/stream/manager.go new file mode 100644 index 00000000..580d492d --- /dev/null +++ b/packages/cli/internal/device_connect/transport/stream/manager.go @@ -0,0 +1,154 @@ +package stream + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/core" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" + "github.com/bluenviron/mediacommon/v2/pkg/codecs/mpeg4audio" +) + +// StreamManager handles high-level streaming operations with protocol abstraction +type StreamManager struct { + logger *slog.Logger +} + +// NewStreamManager creates a new stream manager +func NewStreamManager(logger *slog.Logger) *StreamManager { + return &StreamManager{ + logger: logger, + } +} + +// StreamConfig contains configuration for starting a stream +type StreamConfig struct { + DeviceSerial string + Mode string // "webm" or "mp4" + VideoWidth int + VideoHeight int +} + +// StreamResult contains the result of starting a stream +type StreamResult struct { + CodecParams *CodecParams + VideoCh <-chan core.VideoSample + AudioCh <-chan core.AudioSample + Source *scrcpy.Source + Cleanup func() +} + +// StartStream starts a mixed audio/video stream with protocol abstraction +func (sm *StreamManager) StartStream(ctx context.Context, config StreamConfig) (*StreamResult, error) { + // Get or create scrcpy source with specified mode + // Use background context so the shared source is not cancelled when HTTP request ends + source, err := scrcpy.StartSourceWithMode(config.DeviceSerial, context.Background(), config.Mode) + if err != nil { + return nil, fmt.Errorf("failed to start scrcpy source: %w", err) + } + + // Generate unique subscriber IDs for this connection + videoSubscriberID := fmt.Sprintf("%s_video_%d", config.Mode, time.Now().UnixNano()) + audioSubscriberID := fmt.Sprintf("%s_audio_%d", config.Mode, time.Now().UnixNano()) + + // Subscribe to video and audio streams + videoCh := source.SubscribeVideo(videoSubscriberID, 1000) + audioCh := source.SubscribeAudio(audioSubscriberID, 1000) + + // Prepare cleanup function + cleanup := func() { + source.UnsubscribeVideo(videoSubscriberID) + source.UnsubscribeAudio(audioSubscriberID) + } + + // Prepare codec parameters with protocol-agnostic initialization + codecParams, err := sm.initializeCodecParams(config, source) + if err != nil { + cleanup() + return nil, fmt.Errorf("failed to initialize codec parameters: %w", err) + } + + return &StreamResult{ + CodecParams: codecParams, + VideoCh: videoCh, + AudioCh: audioCh, + Source: source, + Cleanup: cleanup, + }, nil +} + +// initializeCodecParams handles protocol-specific codec parameter initialization +func (sm *StreamManager) initializeCodecParams(config StreamConfig, source *scrcpy.Source) (*CodecParams, error) { + // Prepare codec parameters + codecParams := &CodecParams{ + VideoSPS: nil, // Will be extracted from stream + VideoPPS: nil, // Will be extracted from stream + } + + // Set audio config for MP4 + if config.Mode == "mp4" { + codecParams.AudioConfig = mpeg4audio.AudioSpecificConfig{ + Type: 2, // AAC + SampleRate: 48000, + ChannelCount: 2, + } + } + + // Try to get SPS/PPS from cached data using protocol abstraction + spsPpsExtractor := NewSpsPpsExtractor(sm.logger) + sps, pps, err := spsPpsExtractor.ExtractFromCache(source, config.DeviceSerial) + if err != nil { + sm.logger.Warn("Failed to extract SPS/PPS from cache", "device", config.DeviceSerial, "error", err) + // Continue without SPS/PPS - they will be extracted from the first frame + } else if sps != nil && pps != nil { + codecParams.VideoSPS = sps + codecParams.VideoPPS = pps + sm.logger.Info("SPS/PPS extracted from cache", "device", config.DeviceSerial, "sps_size", len(sps), "pps_size", len(pps)) + } + + return codecParams, nil +} + +// ConvertToMuxerSamples converts scrcpy samples to muxer samples +func (sm *StreamManager) ConvertToMuxerSamples(videoSrc <-chan core.VideoSample, audioSrc <-chan core.AudioSample) (chan VideoSample, chan AudioSample) { + videoCh := make(chan VideoSample, 1000) + audioCh := make(chan AudioSample, 1000) + + // Start channel converters + go sm.convertVideoChannel(videoSrc, videoCh) + go sm.convertAudioChannel(audioSrc, audioCh) + + return videoCh, audioCh +} + +// convertVideoChannel converts scrcpy video samples to our VideoSample format +func (sm *StreamManager) convertVideoChannel(src <-chan core.VideoSample, dst chan<- VideoSample) { + defer close(dst) + for sample := range src { + // Simple keyframe detection based on NAL unit type + isKeyFrame := false + if len(sample.Data) > 0 { + nalType := sample.Data[0] & 0x1F + isKeyFrame = (nalType == 5) // IDR frame + } + + dst <- VideoSample{ + Data: sample.Data, + PTS: sample.PTS, + IsKeyFrame: isKeyFrame, + } + } +} + +// convertAudioChannel converts scrcpy audio samples to our AudioSample format +func (sm *StreamManager) convertAudioChannel(src <-chan core.AudioSample, dst chan<- AudioSample) { + defer close(dst) + for sample := range src { + dst <- AudioSample{ + Data: sample.Data, + PTS: sample.PTS, + } + } +} diff --git a/packages/cli/internal/device_connect/transport/stream/muxer.go b/packages/cli/internal/device_connect/transport/stream/muxer.go new file mode 100644 index 00000000..f4c7a4a1 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/stream/muxer.go @@ -0,0 +1,36 @@ +package stream + +// Muxer defines a unified interface for audio/video muxing +type Muxer interface { + // Initialize sets up the muxer with video dimensions and codec parameters + Initialize(width, height int, codecParams *CodecParams) error + + // Stream processes video and audio samples from channels + Stream(videoCh <-chan VideoSample, audioCh <-chan AudioSample) error + + // Close cleans up resources + Close() error +} + +// VideoSample represents a video frame sample +type VideoSample struct { + Data []byte + PTS int64 + IsKeyFrame bool +} + +// AudioSample represents an audio frame sample +type AudioSample struct { + Data []byte + PTS int64 +} + +// CodecParams contains codec-specific parameters +type CodecParams struct { + // Video codec parameters + VideoSPS []byte + VideoPPS []byte + + // Audio codec parameters + AudioConfig interface{} // Can be mpeg4audio.AudioSpecificConfig for MP4, or nil for WebM +} diff --git a/packages/cli/internal/device_connect/transport/stream/muxer_test.go b/packages/cli/internal/device_connect/transport/stream/muxer_test.go new file mode 100644 index 00000000..5de67063 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/stream/muxer_test.go @@ -0,0 +1,214 @@ +package stream + +import ( + "bytes" + "io" + "log/slog" + "os" + "testing" + "time" + + "github.com/bluenviron/mediacommon/v2/pkg/codecs/mpeg4audio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 测试用的样本数据 +var ( + testVideoSample = VideoSample{ + Data: append(append([]byte{}, testSPS...), append(testPPS, testIDR...)...), + PTS: 0, + IsKeyFrame: true, + } + + testAudioSample = AudioSample{ + Data: testAACFrame, + PTS: 0, + } +) + +// TestMuxerInterface 测试 Muxer 接口的通用行为 +func TestMuxerInterface(t *testing.T) { + tests := []struct { + name string + factory func(io.Writer) Muxer + }{ + { + name: "WebMMuxer", + factory: func(w io.Writer) Muxer { + return NewWebMMuxer(w) + }, + }, + { + name: "FMP4Muxer", + factory: func(w io.Writer) Muxer { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn})) + return NewFMP4Muxer(w, logger) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + writer := tt.factory(&buf) + + // 测试 Initialize + codecParams := &CodecParams{ + VideoSPS: testSPS, + VideoPPS: testPPS, + } + + // 为 FMP4 设置音频配置 + if tt.name == "FMP4MuxedWriter" { + codecParams.AudioConfig = mpeg4audio.AudioSpecificConfig{ + Type: 2, + SampleRate: 48000, + ChannelCount: 2, + } + } + + err := writer.Initialize(1920, 1080, codecParams) + require.NoError(t, err, "Initialize should succeed") + + // 测试 Stream + videoCh := make(chan VideoSample, 10) + audioCh := make(chan AudioSample, 10) + + // 发送测试数据 + go func() { + defer close(videoCh) + defer close(audioCh) + + for i := 0; i < 5; i++ { + videoSample := testVideoSample + videoSample.PTS = int64(i * 33000) // 30fps + videoCh <- videoSample + + audioSample := testAudioSample + audioSample.PTS = int64(i * 21333) // 48kHz + audioCh <- audioSample + + time.Sleep(10 * time.Millisecond) // 模拟实时流 + } + }() + + // 启动流处理 + err = writer.Stream(videoCh, audioCh) + require.NoError(t, err, "Stream should complete successfully") + + // 验证输出数据 + assert.Greater(t, buf.Len(), 0, "Should have written data") + + // 测试 Close + err = writer.Close() + require.NoError(t, err, "Close should succeed") + }) + } +} + +// TestMuxerErrorHandling 测试错误处理 +func TestMuxerErrorHandling(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn})) + writer := NewFMP4Muxer(&buf, logger) + + // 测试未初始化就 Stream + videoCh := make(chan VideoSample) + audioCh := make(chan AudioSample) + close(videoCh) + close(audioCh) + + err := writer.Stream(videoCh, audioCh) + assert.Error(t, err, "Should error when not initialized") +} + +// TestMuxerConcurrency 测试并发安全性 +func TestMuxerConcurrency(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn})) + writer := NewFMP4Muxer(&buf, logger) + + codecParams := &CodecParams{ + VideoSPS: testSPS, + VideoPPS: testPPS, + AudioConfig: mpeg4audio.AudioSpecificConfig{ + Type: 2, + SampleRate: 48000, + ChannelCount: 2, + }, + } + + err := writer.Initialize(1920, 1080, codecParams) + require.NoError(t, err) + + // 并发发送数据 + videoCh := make(chan VideoSample, 100) + audioCh := make(chan AudioSample, 100) + + // 启动多个发送者 + for i := 0; i < 3; i++ { + go func(id int) { + for j := 0; j < 10; j++ { + videoSample := testVideoSample + videoSample.PTS = int64((id*10 + j) * 33000) + videoCh <- videoSample + + audioSample := testAudioSample + audioSample.PTS = int64((id*10 + j) * 21333) + audioCh <- audioSample + } + }(i) + } + + // 启动流处理 + go func() { + time.Sleep(100 * time.Millisecond) + close(videoCh) + close(audioCh) + }() + + err = writer.Stream(videoCh, audioCh) + require.NoError(t, err) + + // 验证输出 + assert.Greater(t, buf.Len(), 0, "Should have written data under concurrent load") +} + +// BenchmarkMuxer 性能测试 +func BenchmarkMuxer(b *testing.B) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + writer := NewFMP4Muxer(&buf, logger) + + codecParams := &CodecParams{ + VideoSPS: testSPS, + VideoPPS: testPPS, + AudioConfig: mpeg4audio.AudioSpecificConfig{ + Type: 2, + SampleRate: 48000, + ChannelCount: 2, + }, + } + + writer.Initialize(1920, 1080, codecParams) + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + videoCh := make(chan VideoSample, 10) + audioCh := make(chan AudioSample, 10) + + // 发送数据 + go func() { + defer close(videoCh) + defer close(audioCh) + for j := 0; j < 10; j++ { + videoCh <- VideoSample{Data: testIDR, PTS: int64(j * 33000), IsKeyFrame: true} + audioCh <- AudioSample{Data: testAACFrame, PTS: int64(j * 21333)} + } + }() + + writer.Stream(videoCh, audioCh) + } +} diff --git a/packages/cli/internal/device_connect/transport/stream/sps_pps_extractor.go b/packages/cli/internal/device_connect/transport/stream/sps_pps_extractor.go new file mode 100644 index 00000000..f78aef39 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/stream/sps_pps_extractor.go @@ -0,0 +1,112 @@ +package stream + +import ( + "bytes" + "fmt" + "log/slog" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/scrcpy" +) + +// SpsPpsExtractor handles H.264 SPS/PPS parameter extraction with protocol abstraction +type SpsPpsExtractor struct { + logger *slog.Logger +} + +// NewSpsPpsExtractor creates a new SPS/PPS extractor +func NewSpsPpsExtractor(logger *slog.Logger) *SpsPpsExtractor { + return &SpsPpsExtractor{ + logger: logger, + } +} + +// ExtractFromCache extracts SPS/PPS parameters from cached data +func (e *SpsPpsExtractor) ExtractFromCache(source *scrcpy.Source, deviceSerial string) ([]byte, []byte, error) { + var sps, pps []byte + var spsPpsExtracted bool + + // Poll cached SPS/PPS for a short time to avoid forcing keyframe/reset + pollStart := time.Now() + for !spsPpsExtracted && time.Since(pollStart) < 3*time.Second { + spsPpsData := source.GetSpsPps() + e.logger.Info("Checking for cached SPS/PPS", "device", deviceSerial, "cache_size", len(spsPpsData)) + + if len(spsPpsData) > 0 { + e.logger.Info("Processing cached SPS/PPS data", "device", deviceSerial, "size", len(spsPpsData), "first_byte", spsPpsData[0]) + + // Extract SPS/PPS using protocol-specific logic + extractedSps, extractedPps, err := e.extractSpsPps(spsPpsData, deviceSerial) + if err == nil && extractedSps != nil && extractedPps != nil { + sps, pps = extractedSps, extractedPps + spsPpsExtracted = true + e.logger.Info("SPS/PPS extracted from cache", "device", deviceSerial, "sps_size", len(sps), "pps_size", len(pps)) + break + } + } + time.Sleep(50 * time.Millisecond) + } + + if !spsPpsExtracted { + return nil, nil, fmt.Errorf("failed to extract SPS/PPS from cache within timeout") + } + + return sps, pps, nil +} + +// extractSpsPps handles protocol-specific SPS/PPS extraction +func (e *SpsPpsExtractor) extractSpsPps(data []byte, deviceSerial string) ([]byte, []byte, error) { + var sps, pps []byte + + // Parse SPS/PPS from cached data, support both Annex-B and avcC + if len(data) > 0 && data[0] == 0x01 { + // avcC format + e.logger.Info("Detected avcC format, parsing...", "device", deviceSerial) + if ps, pp, ok := ParseAvccForSpsPps(data); ok { + sps, pps = ps, pp + e.logger.Info("SPS/PPS extracted from avcC cache", "device", deviceSerial, "sps_size", len(sps), "pps_size", len(pps)) + return sps, pps, nil + } else { + e.logger.Warn("Failed to parse avcC format", "device", deviceSerial) + } + } + + if len(sps) == 0 || len(pps) == 0 { + // Try Annex-B split + e.logger.Info("Trying Annex-B format", "device", deviceSerial) + sps, pps = e.extractFromAnnexB(data, deviceSerial) + } + + if len(sps) > 0 && len(pps) > 0 { + return sps, pps, nil + } + + return nil, nil, fmt.Errorf("could not extract SPS/PPS from data") +} + +// extractFromAnnexB extracts SPS/PPS from Annex-B format data +func (e *SpsPpsExtractor) extractFromAnnexB(data []byte, deviceSerial string) ([]byte, []byte) { + var sps, pps []byte + + startCode := []byte{0x00, 0x00, 0x00, 0x01} + parts := bytes.Split(data, 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 + sps = nal + e.logger.Info("Found SPS from cache", "device", deviceSerial, "size", len(sps)) + case 8: // PPS + pps = nal + e.logger.Info("Found PPS from cache", "device", deviceSerial, "size", len(pps)) + } + } + + return sps, pps +} diff --git a/packages/cli/internal/device_connect/transport/stream/webm_muxer.go b/packages/cli/internal/device_connect/transport/stream/webm_muxer.go new file mode 100644 index 00000000..42196f50 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/stream/webm_muxer.go @@ -0,0 +1,305 @@ +package stream + +import ( + "context" + "fmt" + "io" + "log/slog" + "sync" + "time" + + "github.com/at-wat/ebml-go/mkvcore" + "github.com/at-wat/ebml-go/webm" +) + +// WebMMuxer provides WebM container for mixed audio and video streaming +type WebMMuxer struct { + writer io.Writer + audioWriter webm.BlockWriteCloser + videoWriter webm.BlockWriteCloser + logger *slog.Logger + initialized bool + audioTimestamp time.Duration + videoTimestamp time.Duration + videoWidth int + videoHeight int +} + +// NewWebMMuxer creates a new WebM muxer for mixed streams +func NewWebMMuxer(writer io.Writer) *WebMMuxer { + return &WebMMuxer{ + writer: writer, + logger: slog.With("component", "webm_mixed_muxer"), + } +} + +// NewWebMMuxerWithDimensions creates a new WebM muxer with specific video dimensions +func NewWebMMuxerWithDimensions(writer io.Writer, width, height int) *WebMMuxer { + return &WebMMuxer{ + writer: writer, + logger: slog.With("component", "webm_mixed_muxer"), + videoWidth: width, + videoHeight: height, + } +} + +// writerCloser wraps an io.Writer with basic error handling +type writerCloser struct { + writer io.Writer + logger *slog.Logger + closed bool +} + +func (wc *writerCloser) Write(p []byte) (n int, err error) { + if wc.closed { + return 0, io.ErrClosedPipe + } + + n, err = wc.writer.Write(p) + if err != nil { + wc.logger.Warn("Write error detected, marking writer as closed", + "error", err, + "error_type", fmt.Sprintf("%T", err), + "data_size", len(p), + "bytes_written", n) + wc.closed = true + } + return n, err +} + +func (wc *writerCloser) Close() error { + wc.closed = true + return nil +} + +// WriteHeader initializes the WebM container with audio and video tracks +func (m *WebMMuxer) WriteHeader() error { + if m.initialized { + return nil + } + + m.logger.Info("🎬 Initializing WebM container for mixed audio/video stream") + + // Wrap writer with basic error handling + writeCloser := &writerCloser{ + writer: m.writer, + logger: m.logger, + closed: false, + } + + // Create WebM writer with audio and video track configuration + writers, err := webm.NewSimpleBlockWriter(writeCloser, []webm.TrackEntry{ + { + Name: "Video", + TrackNumber: 1, + TrackUID: 1, + CodecID: "V_MPEG4/ISO/AVC", // H.264 + TrackType: 1, // Video track type + DefaultDuration: 33333333, // ~30fps in nanoseconds + Video: &webm.Video{ + PixelWidth: uint64(m.getVideoWidth()), + PixelHeight: uint64(m.getVideoHeight()), + }, + }, + { + Name: "Audio", + TrackNumber: 2, + TrackUID: 2, + 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 + }, + }, + }, mkvcore.WithOnFatalHandler(func(err error) { + m.logger.Warn("WebM error occurred, will trigger client reconnect", "error", err) + // Reset state for clean reconnection + m.initialized = false + m.audioWriter = nil + m.videoWriter = nil + })) + + if err != nil { + m.logger.Error("Failed to create WebM writer", "error", err) + return err + } + + // Get the video and audio writers from the slice + m.videoWriter = writers[0] // Video is track 1 + m.audioWriter = writers[1] // Audio is track 2 + m.initialized = true + + m.logger.Info("✅ WebM mixed stream container initialized successfully") + return nil +} + +// WriteVideoFrame writes an H.264 video frame to the WebM container +func (m *WebMMuxer) WriteVideoFrame(h264Data []byte, timestamp time.Duration) error { + if !m.initialized || m.videoWriter == nil { + return fmt.Errorf("WebM muxer not initialized") + } + + if len(h264Data) == 0 { + return nil + } + + // Convert timestamp to nanoseconds + ns := uint64(timestamp.Nanoseconds()) + + // WebM container expects Annex-B format, so write H.264 data directly + _, err := m.videoWriter.Write(true, int64(ns), h264Data) + if err != nil { + m.logger.Error("Failed to write video frame", "error", err, "size", len(h264Data)) + return err + } + + m.videoTimestamp = timestamp + m.logger.Debug("Video frame written", "size", len(h264Data), "timestamp", timestamp) + return nil +} + +// WriteAudioFrame writes an Opus audio frame to the WebM container +func (m *WebMMuxer) WriteAudioFrame(opusData []byte, timestamp time.Duration) error { + if !m.initialized || m.audioWriter == nil { + return fmt.Errorf("WebM muxer not initialized") + } + + if len(opusData) == 0 { + return nil + } + + // Convert timestamp to nanoseconds + ns := uint64(timestamp.Nanoseconds()) + + // Write Opus data to audio track + _, err := m.audioWriter.Write(true, int64(ns), opusData) + if err != nil { + m.logger.Error("Failed to write audio frame", "error", err, "size", len(opusData)) + return err + } + + m.audioTimestamp = timestamp + m.logger.Debug("Audio frame written", "size", len(opusData), "timestamp", timestamp) + return nil +} + +// Close finalizes the WebM container +func (m *WebMMuxer) Close() error { + if m.videoWriter != nil { + if err := m.videoWriter.Close(); err != nil { + m.logger.Warn("Video writer close error", "error", err) + } + m.videoWriter = nil + } + + if m.audioWriter != nil { + m.logger.Info("🎵 Finalizing WebM mixed stream container", + "video_timestamp", m.videoTimestamp.Truncate(time.Millisecond), + "audio_timestamp", m.audioTimestamp.Truncate(time.Millisecond)) + + if err := m.audioWriter.Close(); err != nil { + m.logger.Warn("Audio writer close error", "error", err) + } + m.audioWriter = nil + } + + m.initialized = false + m.logger.Info("✅ WebM mixed stream muxer closed successfully") + return nil +} + +// getVideoWidth returns the video width, defaulting to 1920 if not set +func (m *WebMMuxer) getVideoWidth() int { + if m.videoWidth > 0 { + return m.videoWidth + } + return 1920 // Default width +} + +// getVideoHeight returns the video height, defaulting to 1080 if not set +func (m *WebMMuxer) getVideoHeight() int { + if m.videoHeight > 0 { + return m.videoHeight + } + return 1080 // Default height +} + +// Initialize implements the Muxer interface +func (m *WebMMuxer) Initialize(width, height int, params *CodecParams) error { + if m.initialized { + return nil + } + + m.videoWidth = width + m.videoHeight = height + + err := m.WriteHeader() + if err == nil { + m.initialized = true + } + return err +} + +// Stream implements the Muxer interface +func (m *WebMMuxer) Stream(videoCh <-chan VideoSample, audioCh <-chan AudioSample) error { + if !m.initialized { + return fmt.Errorf("muxer not initialized") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + wg.Add(2) + + // 视频处理协程 + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case sample, ok := <-videoCh: + if !ok { + return + } + if err := m.WriteVideoFrame( + sample.Data, + time.Duration(sample.PTS)*time.Nanosecond, + ); err != nil { + m.logger.Error("Failed to write video frame", "error", err) + cancel() + return + } + } + } + }() + + // 音频处理协程 + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case sample, ok := <-audioCh: + if !ok { + return + } + if err := m.WriteAudioFrame( + sample.Data, + time.Duration(sample.PTS)*time.Nanosecond, + ); err != nil { + m.logger.Error("Failed to write audio frame", "error", err) + cancel() + return + } + } + } + }() + + wg.Wait() + return nil +} 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..5d313430 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/webrtc/bridge.go @@ -0,0 +1,109 @@ +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 with explicit webrtc mode + src, err := scrcpy.StartSourceWithMode(deviceSerial, context.Background(), "webrtc") + 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/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/transport/webrtc/peer_connection.go b/packages/cli/internal/device_connect/transport/webrtc/peer_connection.go new file mode 100644 index 00000000..998f5bb8 --- /dev/null +++ b/packages/cli/internal/device_connect/transport/webrtc/peer_connection.go @@ -0,0 +1,153 @@ +package webrtc + +import ( + "fmt" + "log" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/util" + "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 configuration optimized for reconnection + config := webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{}, + // Add aggressive ICE restart configuration + ICECandidatePoolSize: 1, + } + + 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) { + // 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) { + // 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) { + var videoTrack *webrtc.TrackLocalStaticSample + var err error + + switch codecType { + case "h264": + videoTrack, err = webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", + }, + "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) + } + + // 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) { + var audioTrack *webrtc.TrackLocalStaticSample + var err error + + switch codecType { + case "opus": + audioTrack, err = webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, + ClockRate: 48000, + Channels: 2, + }, + "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) + } + + // Audio track added successfully (log.Printf can be uncommented for debugging) + // 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) +} 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..0ff2c83f --- /dev/null +++ b/packages/cli/internal/device_connect/transport/webrtc/transport.go @@ -0,0 +1,276 @@ +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/device_connect/transport/control" + "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 *control.Handler + + // 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 = control.NewHandler(nil, 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/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/proc_group/proc_group_unix.go b/packages/cli/internal/proc_group/proc_group_unix.go new file mode 100644 index 00000000..a2c5e979 --- /dev/null +++ b/packages/cli/internal/proc_group/proc_group_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package procgroup + +import ( + "os/exec" + "syscall" +) + +func SetProcGrp(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } +} \ No newline at end of file diff --git a/packages/cli/internal/proc_group/proc_group_windows.go b/packages/cli/internal/proc_group/proc_group_windows.go new file mode 100644 index 00000000..3352620e --- /dev/null +++ b/packages/cli/internal/proc_group/proc_group_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package procgroup + +import ( + "os/exec" + "syscall" +) + +func SetProcGrp(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, + } +} diff --git a/packages/cli/internal/profile/mgr.go b/packages/cli/internal/profile/mgr.go index 76201e5c..bf80b363 100644 --- a/packages/cli/internal/profile/mgr.go +++ b/packages/cli/internal/profile/mgr.go @@ -3,17 +3,19 @@ package profile import ( "encoding/base64" "encoding/json" - "errors" "fmt" "io" "net/http" "net/url" "os" + "path" "path/filepath" "strings" "github.com/babelcloud/gbox/packages/cli/config" + "github.com/babelcloud/gbox/packages/cli/internal/util" "github.com/pelletier/go-toml/v2" + "github.com/pkg/errors" ) // Common error messages @@ -40,6 +42,7 @@ type Profile struct { OrgSlug string `toml:"org_slug,omitempty"` APIKey string `toml:"key"` BaseURL string `toml:"base_url,omitempty"` + Rack string `toml:"rack,omtiempty"` } // ProfileDefaults represents global defaults @@ -53,6 +56,11 @@ type OrgInfo struct { Slug string } +type RackInfo struct { + Id string `json:"id"` + Name string `json:"name"` +} + // ProfileManager manages profile files type ProfileManager struct { config ProfileConfig @@ -222,137 +230,123 @@ func (pm *ProfileManager) listTable() { } } - // Calculate column widths based on content - maxIDLen := 2 // "ID" header - maxKeyLen := 3 // "Key" header - maxOrgLen := 12 // "Organization" header - maxBaseURLLen := 8 // "Base URL" header + // Prepare data for RenderTable with sorted order + profileIDs := make([]string, 0, len(pm.config.Profiles)) + for id := range pm.config.Profiles { + profileIDs = append(profileIDs, id) + } - for id, profile := range pm.config.Profiles { - // Add arrow to current profile ID for width calculation - displayID := id - if id == pm.config.Current { - displayID = "→ " + id - } - if len(displayID) > maxIDLen { - maxIDLen = len(displayID) + // Sort profile IDs to maintain consistent order + for i := 0; i < len(profileIDs); i++ { + for j := i + 1; j < len(profileIDs); j++ { + if profileIDs[i] > profileIDs[j] { + profileIDs[i], profileIDs[j] = profileIDs[j], profileIDs[i] + } } + } - // Calculate masked key width + tableData := make([]map[string]interface{}, len(profileIDs)) + for i, id := range profileIDs { + profile := pm.config.Profiles[id] + isCurrent := id == pm.config.Current maskedKey := pm.GetMaskedAPIKey(profile.APIKey) - if len(maskedKey) > maxKeyLen { - maxKeyLen = len(maskedKey) + + // Format arrow and ID separately + arrow := " " // Default to 2 spaces for alignment + if isCurrent { + arrow = "\033[32m→\033[0m" // Just the arrow } - if len(profile.GetOrgName()) > maxOrgLen { - maxOrgLen = len(profile.GetOrgName()) + row := map[string]interface{}{ + "arrow": arrow, + "id": id, + "key": maskedKey, + "org": profile.GetOrgName(), } + if showBaseURL { baseURL := profile.BaseURL if baseURL == "" { baseURL = pm.config.Defaults.BaseURL + " (default)" } - if len(baseURL) > maxBaseURLLen { - maxBaseURLLen = len(baseURL) - } + row["base_url"] = baseURL } - } - // Add some padding - maxIDLen += 2 - maxKeyLen += 2 - maxOrgLen += 2 - maxBaseURLLen += 2 + tableData[i] = row + } - // Print header based on whether base URL column is needed + // Define table columns + var columns []util.TableColumn if showBaseURL { - fmt.Printf(" %-*s %-*s %-*s %-*s\n", maxIDLen-2, "ID", maxKeyLen, "Key", maxOrgLen, "Organization", maxBaseURLLen, "Base URL") - fmt.Println(" " + strings.Repeat("-", maxIDLen+maxKeyLen+maxOrgLen+maxBaseURLLen)) + columns = []util.TableColumn{ + {Header: " ", Key: "arrow"}, + {Header: "ID", Key: "id"}, + {Header: "Key", Key: "key"}, + {Header: "Organization", Key: "org"}, + {Header: "Base URL", Key: "base_url"}, + } } else { - fmt.Printf(" %-*s %-*s %-*s\n", maxIDLen-2, "ID", maxKeyLen, "Key", maxOrgLen, "Organization") - fmt.Println(" " + strings.Repeat("-", maxIDLen+maxKeyLen+maxOrgLen-1)) - } - - // Print profiles - for id, profile := range pm.config.Profiles { - isCurrent := id == pm.config.Current - - // Get masked key - maskedKey := pm.GetMaskedAPIKey(profile.APIKey) - - if showBaseURL { - baseURL := profile.BaseURL - if baseURL == "" { - baseURL = pm.config.Defaults.BaseURL + " (default)" - } - if isCurrent { - fmt.Print("\033[32m→ ") // Color the arrow and space - fmt.Printf("\033[32m%-*s\033[0m %-*s %-*s %-*s\n", maxIDLen-2, id, maxKeyLen, maskedKey, maxOrgLen, profile.GetOrgName(), maxBaseURLLen, baseURL) - } else { - fmt.Printf(" %-*s %-*s %-*s %-*s\n", maxIDLen-2, id, maxKeyLen, maskedKey, maxOrgLen, profile.GetOrgName(), maxBaseURLLen, baseURL) - } - } else { - if isCurrent { - fmt.Print("\033[32m→ ") // Color the arrow and space - fmt.Printf("\033[32m%-*s\033[0m %-*s %-*s\n", maxIDLen-2, id, maxKeyLen, maskedKey, maxOrgLen, profile.GetOrgName()) - } else { - fmt.Printf(" %-*s %-*s %-*s\n", maxIDLen-2, id, maxKeyLen, maskedKey, maxOrgLen, profile.GetOrgName()) - } + columns = []util.TableColumn{ + {Header: " ", Key: "arrow"}, + {Header: "ID", Key: "id"}, + {Header: "Key", Key: "key"}, + {Header: "Organization", Key: "org"}, } } + + // Add indentation prefix + fmt.Print(" ") + util.RenderTable(columns, tableData) } // ListTableForSelection displays profiles in table format for selection (used in profile use command) func (pm *ProfileManager) ListTableForSelection() { - // Calculate column widths based on content - maxIDLen := 2 // "ID" header - maxKeyLen := 3 // "Key" header - maxOrgLen := 12 // "Organization" header - - for id, profile := range pm.config.Profiles { - // Add arrow to current profile ID for width calculation - displayID := id - if id == pm.config.Current { - displayID = "→ " + id - } - if len(displayID) > maxIDLen { - maxIDLen = len(displayID) - } - - // Calculate masked key width - maskedKey := pm.GetMaskedAPIKey(profile.APIKey) - if len(maskedKey) > maxKeyLen { - maxKeyLen = len(maskedKey) - } + // Prepare data for RenderTable with sorted order + profileIDs := make([]string, 0, len(pm.config.Profiles)) + for id := range pm.config.Profiles { + profileIDs = append(profileIDs, id) + } - if len(profile.GetOrgName()) > maxOrgLen { - maxOrgLen = len(profile.GetOrgName()) + // Sort profile IDs to maintain consistent order + for i := 0; i < len(profileIDs); i++ { + for j := i + 1; j < len(profileIDs); j++ { + if profileIDs[i] > profileIDs[j] { + profileIDs[i], profileIDs[j] = profileIDs[j], profileIDs[i] + } } } - // Add some padding - maxIDLen += 2 - maxKeyLen += 2 - maxOrgLen += 2 - - // Print header - fmt.Printf(" %-*s %-*s %-*s\n", maxIDLen-2, "ID", maxKeyLen, "Key", maxOrgLen, "Organization") - fmt.Println(" " + strings.Repeat("-", maxIDLen+maxKeyLen+maxOrgLen-1)) - - // Print profiles - for id, profile := range pm.config.Profiles { + tableData := make([]map[string]interface{}, len(profileIDs)) + for i, id := range profileIDs { + profile := pm.config.Profiles[id] isCurrent := id == pm.config.Current - - // Get masked key maskedKey := pm.GetMaskedAPIKey(profile.APIKey) + // Format arrow and ID separately + arrow := " " // Default to 2 spaces for alignment if isCurrent { - fmt.Print("\033[32m→ ") // Color the arrow and space - fmt.Printf("\033[32m%-*s\033[0m %-*s %-*s\n", maxIDLen-2, id, maxKeyLen, maskedKey, maxOrgLen, profile.GetOrgName()) - } else { - fmt.Printf(" %-*s %-*s %-*s\n", maxIDLen-2, id, maxKeyLen, maskedKey, maxOrgLen, profile.GetOrgName()) + arrow = "\033[32m→\033[0m" // Just the arrow + } + + tableData[i] = map[string]interface{}{ + "arrow": arrow, + "id": id, + "key": maskedKey, + "org": profile.GetOrgName(), } } + + // Define table columns + columns := []util.TableColumn{ + {Header: " ", Key: "arrow"}, + {Header: "ID", Key: "id"}, + {Header: "Key", Key: "key"}, + {Header: "Organization", Key: "org"}, + } + + // Add indentation prefix + fmt.Print(" ") + util.RenderTable(columns, tableData) } // Add adds a new profile @@ -362,6 +356,10 @@ func (pm *ProfileManager) Add(id, org, key, baseURL string) error { baseURL = config.GetBaseURL() } + if strings.HasPrefix(key, "gbox-rack_") { + return pm.addRack(key, baseURL) + } + // Store the effective base URL for this profile effectiveBaseURL := baseURL @@ -471,6 +469,26 @@ func (pm *ProfileManager) Add(id, org, key, baseURL string) error { return pm.Save() } +func (pm *ProfileManager) addRack(key, baseURL string) error { + rackInfo, err := pm.getRackInfoFromAPI(key, baseURL) + if err != nil { + return errors.Wrap(err, "failed to validate API key and get rack info") + } + + profile := Profile{ + Rack: rackInfo.Name, + APIKey: base64.StdEncoding.EncodeToString([]byte(key)), + } + + profile.BaseURL = baseURL + + profileID := "rack_" + rackInfo.Name + pm.config.Profiles[profileID] = profile + pm.config.Current = profileID + + return pm.Save() +} + // Use sets the current profile func (pm *ProfileManager) Use(id string) error { if len(pm.config.Profiles) == 0 { @@ -778,7 +796,7 @@ func maskAPIKey(key string) string { if len(key) <= 8 { return "***" } - return key[:4] + "****" + key[len(key)-4:] + return key[:10] + "****" + key[len(key)-4:] } // toJSON converts data to JSON string @@ -855,6 +873,55 @@ func (pm *ProfileManager) getOrgInfoFromAPI(apiKey, baseURL string) (*OrgInfo, e }, nil } +func (pm *ProfileManager) getRackInfoFromAPI(apiKey, baseURL string) (*RackInfo, error) { + rackUrl, err := url.Parse(baseURL) + if err != nil { + return nil, errors.Wrapf(err, "invalid base url: %s", baseURL) + } + + rackUrl.Path = path.Join("/api/v1/rack") + + req, err := http.NewRequest(http.MethodGet, rackUrl.String(), nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") + } + + req.Header.Set("x-rack-api-key", apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to make request") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid API key") + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Read response body for debugging + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + // Debug: print response body + if os.Getenv("DEBUG") == "true" { + fmt.Fprintf(os.Stderr, "Response body: %s\n", string(body)) + } + + var rackInfo RackInfo + if err := json.Unmarshal(body, &rackInfo); err != nil { + return nil, errors.Wrapf(err, "failed to parse response body: %s", string(body)) + } + + return &rackInfo, nil +} + // GetDevicesURL returns the devices URL for the current profile func (pm *ProfileManager) GetDevicesURL() (string, error) { current := pm.GetCurrent() 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/device_keeper.go b/packages/cli/internal/server/device_keeper.go new file mode 100644 index 00000000..91d04cf9 --- /dev/null +++ b/packages/cli/internal/server/device_keeper.go @@ -0,0 +1,1026 @@ +package server + +import ( + "crypto/tls" + "io" + "log" + "net" + "net/http" + "net/url" + "path" + "runtime/debug" + "sync" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/cloud" + "github.com/babelcloud/gbox/packages/cli/internal/device" + "github.com/babelcloud/gbox/packages/cli/internal/server/handlers" + adb "github.com/basiooo/goadb" + "github.com/dchest/uniuri" + "github.com/pires/go-proxyproto" + "github.com/pkg/errors" + "github.com/vishalkuo/bimap" + "github.com/xtaci/smux" + "k8s.io/utils/keymutex" +) + +var deviceConnectClient = &http.Client{ + Transport: &http.Transport{ + TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), + }, +} + +type deviceInfo struct { + DeviceDTO *handlers.DeviceDTO // Complete device information from handlers package + ExpiresAt time.Time // Expiration time +} + +// reconnectState tracks the reconnection state for a device +type reconnectState struct { + IsReconnecting bool + Attempt int + MaxRetry int + Serial string + DeviceId string + DisconnectedAt time.Time // When the device was disconnected +} + +type DeviceKeeper struct { + adbClient *adb.Adb + deviceWatcher *adb.DeviceWatcher + + adbDeviceBiMap *bimap.BiMap[string, string] + deviceSessions *DeviceMap + + deviceAPI *cloud.DeviceAPI + apAPI *cloud.AccessPointAPI + + // Device info cache with expiration + // Key can be serialno, deviceId (TransportID), or regId + deviceInfoCache map[string]*deviceInfo + infoCacheMu sync.RWMutex + + // Reconnection state tracking + // Key is device serial + reconnectStates map[string]*reconnectState + reconnectMu sync.RWMutex + + mu sync.RWMutex + deviceLock keymutex.KeyMutex +} + +func NewDeviceKeeper() (*DeviceKeeper, error) { + adbClient, err := adb.NewWithConfig(adb.ServerConfig{ + Port: adb.AdbPort, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to create adb client on port %d", adb.AdbPort) + } + return &DeviceKeeper{ + adbClient: adbClient, + adbDeviceBiMap: bimap.NewBiMap[string, string](), + deviceSessions: NewDeviceMap(), + deviceAPI: cloud.NewDeviceAPI(), + apAPI: cloud.NewAccessPointAPI(), + deviceInfoCache: make(map[string]*deviceInfo), + reconnectStates: make(map[string]*reconnectState), + deviceLock: keymutex.NewHashed(10000), + }, nil +} + +func (dm *DeviceKeeper) Start() error { + if err := dm.adbClient.StartServer(); err != nil { + return errors.Wrapf(err, "failed to start ") + } + + dm.deviceWatcher = dm.adbClient.NewDeviceWatcher() + go func() { + for event := range dm.deviceWatcher.C() { + log.Printf("device event: %s %s -> %s", event.Serial, event.OldState, event.NewState) + switch event.NewState { + case adb.StateOnline: + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("recovered from connectAP: device %s event %s goroutine: %v", event.Serial, event.NewState, r) + } + }() + if err := dm.connectAP(event.Serial); err != nil { + log.Print(errors.Wrapf(err, "failed to connect device %s to access point", event.Serial)) + } + }() + + case adb.StateOffline: + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("recovered from disconnectAPForce: device %s event %s goroutine: %v: %s", event.Serial, event.NewState, r, string(debug.Stack())) + } + }() + + if err := dm.disconnectAPForce(event.Serial); err != nil { + log.Print(errors.Wrapf(err, "failed to disconnect device %s from access point", event.Serial)) + } + }() + } + } + if dm.deviceWatcher.Err() != nil { + log.Print(errors.Wrap(dm.deviceWatcher.Err(), "adb device watcher error")) + } + }() + + // Reconnect all registered devices (both Android and desktop) + go func() { + // Give adb watcher some time to detect online devices first + time.Sleep(2 * time.Second) + if err := dm.ReconnectRegisteredDevices(); err != nil { + log.Printf("Failed to reconnect registered devices: %v", err) + } + }() + + // Start periodic cleanup and health check tasks + go dm.startPeriodicCleanup() + + return nil +} + +func (dm *DeviceKeeper) Close() { + if dm.deviceWatcher != nil { + dm.deviceWatcher.Shutdown() + } +} + +// startPeriodicCleanup runs periodic cleanup and health check tasks +func (dm *DeviceKeeper) startPeriodicCleanup() { + cleanupTicker := time.NewTicker(10 * time.Minute) // Run cleanup every 10 minutes + defer cleanupTicker.Stop() + + healthCheckTicker := time.NewTicker(30 * time.Second) // Run health check every 30 seconds + defer healthCheckTicker.Stop() + + for { + select { + case <-cleanupTicker.C: + dm.cleanupDisconnectedDevices() + dm.CleanupExpiredDeviceInfos() + case <-healthCheckTicker.C: + dm.healthCheckAndReconnect() + } + } +} + +// healthCheckAndReconnect checks all registered devices and attempts to reconnect if needed +// Also checks if currently "connected" devices are actually alive +func (dm *DeviceKeeper) healthCheckAndReconnect() { + // First, check all currently connected sessions for liveness + dm.checkConnectedDevicesHealth() + + // Get all registered devices from API + deviceList, err := dm.deviceAPI.GetAll() + if err != nil { + log.Printf("Health check: failed to list devices: %v", err) + return + } + + for _, device := range deviceList.Data { + // Skip if device is not registered + if device.RegId == "" { + continue + } + + serialno := device.Metadata.Serialno + if serialno == "" { + // Desktop devices might use regId as serial + serialno = device.RegId + } + + // Check if device is connected + isConnected := dm.IsDeviceConnected(serialno) + + // Check if device is currently reconnecting + dm.reconnectMu.RLock() + _, isReconnecting := dm.reconnectStates[serialno] + dm.reconnectMu.RUnlock() + + // If not connected and not already reconnecting, try to reconnect + if !isConnected && !isReconnecting { + deviceType := device.Metadata.DeviceType + osType := device.Metadata.OsType + + // For Android devices, check if the physical device is online via ADB + if deviceType == "mobile" && osType == "android" { + // Check if device is visible in ADB + adbDevices, err := dm.adbClient.ListDevices() + if err != nil { + log.Printf("Health check: failed to list adb devices: %v", err) + continue + } + + deviceFound := false + for _, adbDev := range adbDevices { + if adbDev.Serial == serialno { + deviceFound = true + break + } + } + + // Only try to reconnect if device is physically present + if !deviceFound { + continue + } + } + + // Try to connect + log.Printf("Health check: device %s (ID: %s) is registered but not connected, attempting to connect", serialno, device.Id) + go func(serial, deviceId, devType, osType string) { + if err := dm.connectAPUsingDeviceId(serial, deviceId, devType, osType); err != nil { + log.Printf("Health check: failed to connect device %s: %v", serial, err) + } + }(serialno, device.Id, deviceType, osType) + } + } +} + +// checkConnectedDevicesHealth checks if currently connected devices are actually alive +func (dm *DeviceKeeper) checkConnectedDevicesHealth() { + dm.mu.RLock() + // Get all connected serials + sessions := make(map[string]*DeviceSession) + dm.deviceSessions.mu.RLock() + for serial, session := range dm.deviceSessions.sessions { + sessions[serial] = session + } + dm.deviceSessions.mu.RUnlock() + dm.mu.RUnlock() + + // Check each connected session + for serial, session := range sessions { + // Get deviceId before any removal + dm.mu.RLock() + deviceId, _ := dm.adbDeviceBiMap.Get(serial) + dm.mu.RUnlock() + + if session.Mux == nil { + log.Printf("Health check: device %s has nil Mux, triggering reconnection", serial) + dm.delDevice(session) + + // Trigger reconnection if we have deviceId + if deviceId != "" { + go dm.reconnectDeviceWithBackoff(serial, session, deviceId) + } + continue + } + + // Check if the mux is closed or broken + if session.Mux.IsClosed() { + log.Printf("Health check: device %s connection is closed, triggering reconnection", serial) + + // Remove the dead session + dm.delDevice(session) + + // Trigger reconnection + if deviceId != "" { + go dm.reconnectDeviceWithBackoff(serial, session, deviceId) + } + } + } +} + +// cleanupDisconnectedDevices removes mappings for devices that have been disconnected for too long +func (dm *DeviceKeeper) cleanupDisconnectedDevices() { + dm.reconnectMu.Lock() + defer dm.reconnectMu.Unlock() + + now := time.Now() + // Grace period for disconnected devices: 30 minutes + gracePeriod := 30 * time.Minute + + toClean := make([]string, 0) + for serial, state := range dm.reconnectStates { + // Clean up devices that: + // 1. Are not reconnecting (gave up) + // 2. Have been disconnected for more than grace period + if !state.IsReconnecting && now.Sub(state.DisconnectedAt) > gracePeriod { + toClean = append(toClean, serial) + } + } + + // Perform cleanup outside the loop to avoid modification during iteration + for _, serial := range toClean { + // Remove from bimap + dm.mu.Lock() + dm.adbDeviceBiMap.Delete(serial) + dm.mu.Unlock() + + // Remove reconnect state + delete(dm.reconnectStates, serial) + + // Clean up device info cache + dm.infoCacheMu.Lock() + delete(dm.deviceInfoCache, serial) + dm.infoCacheMu.Unlock() + + log.Printf("device %s: cleaned up after %v of being disconnected", serial, gracePeriod) + } + + if len(toClean) > 0 { + log.Printf("Periodic cleanup: removed %d disconnected device(s)", len(toClean)) + } +} + +// getSerialByDeviceId gets the device serial (serialno) by device ID (gbox device ID) +// Supports both Android and desktop devices by checking both adbDeviceBiMap and deviceInfoCache +func (dm *DeviceKeeper) getSerialByDeviceId(deviceId string) string { + // First, try to get from adbDeviceBiMap (for Android devices that are connected) + dm.mu.RLock() + serial, ok := dm.adbDeviceBiMap.GetInverse(deviceId) + dm.mu.RUnlock() + if ok && serial != "" { + return serial + } + + // If not found in adbDeviceBiMap, try to get from device info cache + // This works for both Android and desktop devices + dm.infoCacheMu.RLock() + defer dm.infoCacheMu.RUnlock() + + // Search through cache to find device with matching ID + for _, info := range dm.deviceInfoCache { + if time.Now().After(info.ExpiresAt) { + continue // Skip expired entries + } + if info.DeviceDTO != nil && info.DeviceDTO.ID == deviceId { + // Found matching device ID, return its serialno + if info.DeviceDTO.Serialno != "" { + return info.DeviceDTO.Serialno + } + } + } + + // Also try to match by TransportID or RegId as fallback + for _, info := range dm.deviceInfoCache { + if time.Now().After(info.ExpiresAt) { + continue + } + if info.DeviceDTO != nil { + if info.DeviceDTO.TransportID == deviceId || info.DeviceDTO.RegId == deviceId { + if info.DeviceDTO.Serialno != "" { + return info.DeviceDTO.Serialno + } + } + } + } + + return "" +} + +// getAdbSerialByGboxDeviceId is kept for backward compatibility +// Use getSerialByDeviceId instead for universal device support +func (dm *DeviceKeeper) getAdbSerialByGboxDeviceId(deviceId string) string { + return dm.getSerialByDeviceId(deviceId) +} + +func (dm *DeviceKeeper) connectAP(serial string) error { + devMgr := device.NewManager("android") + ids, err := devMgr.GetIdentifiers(serial) + var deviceList *cloud.DeviceList + + if err != nil { + // Fall back: treat input as a deviceId for non-ADB devices (desktop) + // Try to find device by deviceId directly + deviceList, err = dm.deviceAPI.GetByRegId(serial) + if err != nil || len(deviceList.Data) == 0 { + // If not found by regId, treat serial as deviceId directly + return dm.connectAPUsingDeviceId(serial, serial, "", "") + } + // Found device by regId, use it + dev := deviceList.Data[0] + deviceType := dev.Metadata.DeviceType + osType := dev.Metadata.OsType + return dm.connectAPUsingDeviceId(serial, dev.Id, deviceType, osType) + } + + // Android device: get serialno and androidId + serialno := ids.SerialNo + var androidId string + if ids.AndroidID != nil { + androidId = *ids.AndroidID + } + + deviceList, err = dm.deviceAPI.GetBySerialnoAndAndroidId(serialno, androidId) + if err != nil { + return errors.Wrapf(err, "failed to get GBOX devices with serialno %s and androidId %s", serialno, androidId) + } + if len(deviceList.Data) == 0 { + return errors.Errorf("device %s not registered in GBOX", serial) + } + + dev := deviceList.Data[0] + deviceType := dev.Metadata.DeviceType + osType := dev.Metadata.OsType + return dm.connectAPUsingDeviceId(serial, dev.Id, deviceType, osType) +} + +// connectAPUsingDeviceId establishes AP connection using known gbox deviceId. +// key is used as the map/session key and logging serial (adb serial or deviceId). +// deviceType and osType are stored for device type-specific handling. +func (dm *DeviceKeeper) connectAPUsingDeviceId(key string, deviceId string, deviceType string, osType string) error { + dm.deviceLock.LockKey(key) + defer dm.deviceLock.UnlockKey(key) + + apList, err := dm.apAPI.List() + if err != nil { + return errors.Wrapf(err, "failed to list access point") + } + if len(apList.Data) == 0 { + return errors.Errorf("no access point found") + } + + connectEndpoint, err := url.Parse(apList.Data[0].Endpoint) + if err != nil { + return errors.Wrapf(err, "invalid access point endpoint %s", apList.Data[0].Endpoint) + } + connectEndpoint.Path = path.Join("/devices", deviceId, "connect") + + token, err := dm.deviceAPI.GenerateAccessPointToken(deviceId, connectEndpoint.String()) + if err != nil { + return errors.Wrapf(err, "failed to generate access point token") + } + + mux, err := connectAP(connectEndpoint.String(), token.Token, apList.Data[0].Metadata.Protocol, key) + if err != nil { + return errors.Wrapf(err, "failed to connect device %s to GBOX access point", key) + } + + session := dm.addDevice(key, &DeviceSession{ + Mux: mux, + Serial: key, + DeviceType: deviceType, + OsType: osType, + ReconnectAttempt: 0, + MaxReconnect: 5, // Maximum 5 reconnection attempts + LastError: nil, + }, deviceId) + + // Create minimal device info for cache (full info will be updated when device list is queried) + // This ensures we can look up device platform immediately after connection + dto := &handlers.DeviceDTO{ + ID: deviceId, + Serialno: key, + Platform: deviceType, // deviceType is actually "mobile" or "desktop" here + OS: osType, + DeviceType: "", // Will be filled when device list is queried + } + dm.updateDeviceInfo(dto) + + go dm.processDeviceSession(session, key) + return nil +} + +func (dm *DeviceKeeper) disconnectAP(session *DeviceSession) error { + dm.deviceLock.LockKey(session.Serial) + defer dm.deviceLock.UnlockKey(session.Serial) + + session.Mux.Close() + + dm.delDevice(session) + return nil +} + +func (dm *DeviceKeeper) disconnectAPForce(serial string) error { + session, ok := dm.getDevice(serial) + if ok { + dm.deviceLock.LockKey(serial) + defer dm.deviceLock.UnlockKey(serial) + + if session != nil { + if session.Mux != nil { + session.Mux.Close() + } + dm.delDevice(session) + } + } + + // For physical disconnection (device offline), clean up immediately + // This is different from connection loss which triggers reconnection + dm.mu.Lock() + dm.adbDeviceBiMap.Delete(serial) + dm.mu.Unlock() + + // Clean up reconnect state if exists + dm.reconnectMu.Lock() + delete(dm.reconnectStates, serial) + dm.reconnectMu.Unlock() + + return nil +} + +// unregisterDevice is called when user explicitly unregisters a device +// This should clean up all related data immediately +func (dm *DeviceKeeper) unregisterDevice(serial string) error { + // First disconnect the device + if err := dm.disconnectAPForce(serial); err != nil { + return err + } + + // Clean up device info cache + dm.infoCacheMu.Lock() + delete(dm.deviceInfoCache, serial) + dm.infoCacheMu.Unlock() + + log.Printf("device %s: unregistered and cleaned up all mappings", serial) + return nil +} + +func (dm *DeviceKeeper) getDevice(serial string) (*DeviceSession, bool) { + dm.mu.RLock() + defer dm.mu.RUnlock() + + session, ok := dm.deviceSessions.Get(serial) + return session, ok +} + +// IsDeviceConnected checks if a device is currently connected to AP +func (dm *DeviceKeeper) IsDeviceConnected(serial string) bool { + _, ok := dm.getDevice(serial) + return ok +} + +func (dm *DeviceKeeper) hasDevice(serial string) bool { + dm.mu.RLock() + defer dm.mu.RUnlock() + + _, ok := dm.adbDeviceBiMap.Get(serial) + return ok +} + +func (dm *DeviceKeeper) addDevice(serial string, deviceSession *DeviceSession, gboxDeviceId string) *DeviceSession { + dm.mu.Lock() + defer dm.mu.Unlock() + + session := dm.deviceSessions.Set(serial, deviceSession) + dm.adbDeviceBiMap.Insert(serial, gboxDeviceId) + return session +} + +func (dm *DeviceKeeper) delDevice(session *DeviceSession) bool { + dm.mu.Lock() + defer dm.mu.Unlock() + + // Only delete from session map, keep adbDeviceBiMap for reconnection + return dm.deviceSessions.Delete(session.Serial, session.Token) + // Note: We intentionally keep adbDeviceBiMap entry for reconnection + // Note: We don't delete from platform cache here to allow it to expire naturally + // This helps with reconnection scenarios +} + +// updateDeviceInfo updates the device info cache with complete device information +// Cache expires after 1 hour of inactivity +// Stores device info under multiple keys: serialno, TransportID, ID, and regId (if available) +func (dm *DeviceKeeper) updateDeviceInfo(dto *handlers.DeviceDTO) { + dm.infoCacheMu.Lock() + defer dm.infoCacheMu.Unlock() + + info := &deviceInfo{ + DeviceDTO: dto, + ExpiresAt: time.Now().Add(1 * time.Hour), + } + + // Store under multiple keys for flexible lookup + if dto.Serialno != "" { + dm.deviceInfoCache[dto.Serialno] = info + } + if dto.TransportID != "" && dto.TransportID != dto.Serialno { + dm.deviceInfoCache[dto.TransportID] = info + } + if dto.ID != "" { + dm.deviceInfoCache[dto.ID] = info + } + if dto.RegId != "" { + dm.deviceInfoCache[dto.RegId] = info + } +} + +// GetDeviceInfo gets the complete device information from cache by serialno, deviceId, or regId +// Returns nil if not found or expired +func (dm *DeviceKeeper) GetDeviceInfo(key string) *handlers.DeviceDTO { + dm.infoCacheMu.RLock() + defer dm.infoCacheMu.RUnlock() + + info, ok := dm.deviceInfoCache[key] + if !ok { + return nil + } + + // Check if expired + if time.Now().After(info.ExpiresAt) { + // Clean up expired entry (async) + go dm.cleanupExpiredDeviceInfo(key) + return nil + } + + return info.DeviceDTO +} + +// cleanupExpiredDeviceInfo removes expired entries from cache +func (dm *DeviceKeeper) cleanupExpiredDeviceInfo(key string) { + dm.infoCacheMu.Lock() + defer dm.infoCacheMu.Unlock() + + info, ok := dm.deviceInfoCache[key] + if ok && time.Now().After(info.ExpiresAt) { + delete(dm.deviceInfoCache, key) + } +} + +// CleanupExpiredDeviceInfos removes all expired entries from cache +func (dm *DeviceKeeper) CleanupExpiredDeviceInfos() { + dm.infoCacheMu.Lock() + defer dm.infoCacheMu.Unlock() + + now := time.Now() + for key, info := range dm.deviceInfoCache { + if now.After(info.ExpiresAt) { + delete(dm.deviceInfoCache, key) + } + } +} + +// ReconnectRegisteredDevices reconnects all registered devices on server start +// This is called after server startup to restore device connections +func (dm *DeviceKeeper) ReconnectRegisteredDevices() error { + // Get all registered devices from cloud + deviceList, err := dm.deviceAPI.GetAll() + if err != nil { + return errors.Wrap(err, "failed to get registered devices from cloud") + } + + log.Printf("Attempting to reconnect %d registered device(s)", len(deviceList.Data)) + + for _, dev := range deviceList.Data { + // Skip if device is already connected + if dm.IsDeviceConnected(dev.Metadata.Serialno) { + log.Printf("Device %s (%s) already connected, skipping", dev.Id, dev.Metadata.Serialno) + continue + } + + deviceType := dev.Metadata.DeviceType + osType := dev.Metadata.OsType + serialno := dev.Metadata.Serialno + + // For Android devices, wait for adb watcher to handle connection + // Only reconnect desktop devices here + if deviceType == "desktop" && serialno != "" { + log.Printf("Reconnecting desktop device %s (%s)", dev.Id, serialno) + go func(id, sn, dt, ot string) { + if err := dm.connectAPUsingDeviceId(sn, id, dt, ot); err != nil { + log.Printf("Failed to reconnect device %s: %v", id, err) + } else { + log.Printf("Successfully reconnected device %s", id) + } + }(dev.Id, serialno, deviceType, osType) + } + } + + return nil +} + +func connectAP(url, token, protocol, serial string) (*smux.Session, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to create request to connect to access point %s via %s protocol", url, protocol) + } + req.Header.Set("Connection", "upgrade") + req.Header.Set("Upgrade", protocol) + req.Header.Set("User-Agent", "GBOX-cli") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := deviceConnectClient.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "failed to connect to access point %s via %s protocol", url, protocol) + } + if resp.StatusCode != http.StatusSwitchingProtocols { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return nil, errors.Wrapf(err, "access point does not switch protocol, respond %d: %v", resp.StatusCode, string(body)) + } + + log.Printf("device %s connected access point %s via %s protocol", serial, url, protocol) + rwCloser, ok := resp.Body.(io.ReadWriteCloser) + if !ok { + defer resp.Body.Close() + return nil, errors.Errorf("failed to convert access point connection into read write closer") + } + + session, err := smux.Server(rwCloser, nil) + if err != nil { + defer resp.Body.Close() + return nil, errors.Wrapf(err, "failed to create smux session from access point connection") + } + + return session, nil +} + +func (dm *DeviceKeeper) processDeviceSession(session *DeviceSession, serial string) { + defer func() { + if r := recover(); r != nil { + log.Printf("recovered from device %s processDeviceSession goroutine: %v: %s", serial, r, string(debug.Stack())) + } + }() + + for { + // Check if session.Mux is nil before calling AcceptStream + if session.Mux == nil { + log.Printf("device %s: session.Mux is nil, cannot accept stream", serial) + return + } + + stream, err := session.Mux.AcceptStream() + if err != nil { + // Check if device is still registered (in bimap) + if dm.hasDevice(serial) { + log.Print(errors.Wrapf(err, "device %s session closed. will try to reconnect", serial)) + + // Get deviceId before delDevice (which used to delete from bimap) + deviceId, _ := dm.adbDeviceBiMap.Get(serial) + + // Mark device as disconnected but keep bimap entry for reconnection + if !dm.delDevice(session) { + // Session was already replaced by a newer connection; skip reconnection. + log.Printf("device %s: session token %s superseded by newer connection, skipping reconnection", serial, session.Token) + return + } + + // Start reconnection with exponential backoff + go dm.reconnectDeviceWithBackoff(serial, session, deviceId) + return + } else { + log.Printf("device %s: session closed and device not registered, stopping", serial) + return + } + } + log.Printf("device %s stream %d accepted", serial, stream.ID()) + go processSessionStream(stream, serial) + } +} + +// reconnectDeviceWithBackoff attempts to reconnect a device with exponential backoff +// deviceId is passed in to avoid race condition with bimap +func (dm *DeviceKeeper) reconnectDeviceWithBackoff(serial string, oldSession *DeviceSession, deviceId string) { + defer func() { + if r := recover(); r != nil { + log.Printf("recovered from reconnectDeviceWithBackoff for device %s: %v", serial, r) + } + }() + + if deviceId == "" { + log.Printf("device %s: empty device ID for reconnection", serial) + return + } + + maxAttempts := oldSession.MaxReconnect + if maxAttempts <= 0 { + maxAttempts = 5 + } + + // Create and store reconnection state + dm.setReconnectState(serial, &reconnectState{ + IsReconnecting: true, + Attempt: 0, + MaxRetry: maxAttempts, + Serial: serial, + DeviceId: deviceId, + DisconnectedAt: time.Now(), + }) + defer dm.removeReconnectState(serial) + + for attempt := 1; attempt <= maxAttempts; attempt++ { + // Update reconnection attempt count + dm.updateReconnectAttempt(serial, attempt) + + // Calculate backoff delay: 2^attempt seconds, capped at 60 seconds + backoffSeconds := 1 << uint(attempt) // 2, 4, 8, 16, 32... + if backoffSeconds > 60 { + backoffSeconds = 60 + } + backoffDuration := time.Duration(backoffSeconds) * time.Second + + log.Printf("device %s: reconnection attempt %d/%d (waiting %v)...", serial, attempt, maxAttempts, backoffDuration) + time.Sleep(backoffDuration) + + // Try to reconnect + err := dm.connectAPUsingDeviceId(serial, deviceId, oldSession.DeviceType, oldSession.OsType) + if err == nil { + log.Printf("device %s: reconnection successful on attempt %d", serial, attempt) + return + } + + log.Printf("device %s: reconnection attempt %d/%d failed: %v", serial, attempt, maxAttempts, err) + } + + // Max attempts reached - mark as disconnected permanently + log.Printf("device %s: max reconnection attempts (%d) reached, marking as disconnected", serial, maxAttempts) + + // Keep the state to show "Disconnected" status, don't remove it + dm.reconnectMu.Lock() + if state, ok := dm.reconnectStates[serial]; ok { + state.IsReconnecting = false + state.Attempt = maxAttempts + } + dm.reconnectMu.Unlock() + + // Schedule cleanup of bimap entry after a grace period (5 minutes) + // This allows user to see "Disconnected" status for a while + go func() { + gracePeriod := 5 * time.Minute + log.Printf("device %s: will clean up mapping after %v grace period", serial, gracePeriod) + time.Sleep(gracePeriod) + + // Check if device has reconnected during grace period + if !dm.IsDeviceConnected(serial) { + dm.mu.Lock() + dm.adbDeviceBiMap.Delete(serial) + dm.mu.Unlock() + + // Also clean up reconnect state + dm.reconnectMu.Lock() + delete(dm.reconnectStates, serial) + dm.reconnectMu.Unlock() + + log.Printf("device %s: cleaned up mapping and reconnect state after grace period", serial) + } + }() +} + +// setReconnectState sets the reconnection state for a device +func (dm *DeviceKeeper) setReconnectState(serial string, state *reconnectState) { + dm.reconnectMu.Lock() + defer dm.reconnectMu.Unlock() + dm.reconnectStates[serial] = state +} + +// updateReconnectAttempt updates the current reconnection attempt count +func (dm *DeviceKeeper) updateReconnectAttempt(serial string, attempt int) { + dm.reconnectMu.Lock() + defer dm.reconnectMu.Unlock() + if state, ok := dm.reconnectStates[serial]; ok { + state.Attempt = attempt + } +} + +// removeReconnectState removes the reconnection state for a device +// Only call this when reconnection succeeds +func (dm *DeviceKeeper) removeReconnectState(serial string) { + dm.reconnectMu.Lock() + defer dm.reconnectMu.Unlock() + // Only remove if not at max retries (success case) + if state, ok := dm.reconnectStates[serial]; ok && state.Attempt < state.MaxRetry { + delete(dm.reconnectStates, serial) + } +} + +// getReconnectState gets the reconnection state for a device +// Returns a map to avoid exposing internal reconnectState struct +func (dm *DeviceKeeper) getReconnectState(serial string) map[string]interface{} { + dm.reconnectMu.RLock() + defer dm.reconnectMu.RUnlock() + + state, ok := dm.reconnectStates[serial] + if !ok { + return nil + } + + return map[string]interface{}{ + "isReconnecting": state.IsReconnecting, + "attempt": state.Attempt, + "maxRetry": state.MaxRetry, + "serial": state.Serial, + "deviceId": state.DeviceId, + } +} + +func processSessionStream(stream *smux.Stream, serial string) { + defer func() { + if r := recover(); r != nil { + log.Printf("recovered from device %s stream %d processStream goroutine: %v", serial, stream.ID(), r) + } + }() + + local := proxyproto.NewConn(stream) + defer log.Printf("device %s stream %d closed", serial, stream.ID()) + defer local.Close() + + proxyHeader := local.ProxyHeader() + log.Print(proxyHeader.DestinationAddr.String()) + + host, port, err := net.SplitHostPort(proxyHeader.DestinationAddr.String()) + if err != nil { + log.Print(err) + return + } + if host == "0.0.0.0" { + tlvs, err := proxyHeader.TLVs() + if err != nil { + log.Print(err) + return + } + for _, tlv := range tlvs { + if tlv.Type == proxyproto.PP2_TYPE_AUTHORITY { + host = string(tlv.Value) + break + } + } + ips, err := net.LookupIP(host) + if err != nil { + log.Print(err) + return + } + host = ips[0].String() + } + + remote, err := net.Dial("tcp", net.JoinHostPort(host, port)) + if err != nil { + log.Print(err) + return + } + defer remote.Close() + + var wg sync.WaitGroup + defer wg.Wait() + + wg.Go(func() { + defer local.Close() + if _, err := io.Copy(local, remote); err != nil { + log.Printf("device %s stream %d local <- remote: %v", serial, stream.ID(), err) + } + }) + wg.Go(func() { + defer remote.Close() + if _, err := io.Copy(remote, local); err != nil { + log.Printf("device %s stream %d remote <- local: %v", serial, stream.ID(), err) + } + }) +} + +type DeviceSession struct { + Mux *smux.Session + Token string + Serial string + DeviceType string // mobile or desktop + OsType string // android, linux, windows, macos + ReconnectAttempt int // Current reconnection attempt count + MaxReconnect int // Maximum reconnection attempts (default: 5) + LastError error // Last connection error +} + +type DeviceMap struct { + sessions map[string]*DeviceSession + mu sync.RWMutex +} + +func NewDeviceMap() *DeviceMap { + return &DeviceMap{ + sessions: map[string]*DeviceSession{}, + } +} + +func (dm *DeviceMap) Len() int { + dm.mu.RLock() + defer dm.mu.RUnlock() + + return len(dm.sessions) +} + +func (dm *DeviceMap) Get(serial string) (*DeviceSession, bool) { + dm.mu.RLock() + defer dm.mu.RUnlock() + + session, ok := dm.sessions[serial] + return session, ok +} + +func (dm *DeviceMap) Set(serial string, session *DeviceSession) *DeviceSession { + dm.mu.Lock() + defer dm.mu.Unlock() + + session.Token = uniuri.NewLen(32) + dm.sessions[serial] = session + return session +} + +func (dm *DeviceMap) Delete(serial, token string) bool { + dm.mu.Lock() + defer dm.mu.Unlock() + + if session, ok := dm.sessions[serial]; ok && session.Token == token { + delete(dm.sessions, serial) + return true + } + return false +} + +func (dm *DeviceMap) DeleteForce(serial string) { + dm.mu.Lock() + defer dm.mu.Unlock() + + delete(dm.sessions, serial) +} diff --git a/packages/cli/internal/server/handlers/adb_expose.go b/packages/cli/internal/server/handlers/adb_expose.go new file mode 100644 index 00000000..bc3cec86 --- /dev/null +++ b/packages/cli/internal/server/handlers/adb_expose.go @@ -0,0 +1,426 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "sync" + "time" + + adb_expose "github.com/babelcloud/gbox/packages/cli/internal/adb_expose" + "github.com/babelcloud/gbox/packages/cli/internal/profile" +) + +// ADBExposeHandlers contains handlers for ADB expose functionality +type ADBExposeHandlers struct { + portManager *PortManager + connectionPool *ConnectionPool +} + +// 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"` +} + +// 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 +} + +// Stop stops the port forward +func (pf *PortForward) Stop() { + pf.mu.Lock() + defer pf.mu.Unlock() + + pf.Status = "stopped" + for _, listener := range pf.listeners { + listener.Close() + } +} + +// PortManager manages multiple port forwards +type PortManager struct { + forwards map[string]*PortForward + mu sync.RWMutex +} + +// ConnectionPool manages WebSocket connections +type ConnectionPool struct { + connections map[string]*adb_expose.MultiplexClient + mu sync.RWMutex +} + +// 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"` +} + +// 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), + }, + } +} + +// 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 + } + + var req struct { + 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 + } + + // Validate request + if req.BoxID == "" { + 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{ + "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": "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 + } + } + + 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 := h.startPortForward(adbReq) + if err != nil { + log.Printf("Failed to start ADB port forward: %v", err) + 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{}{ + "success": true, + "message": fmt.Sprintf("ADB port exposed for box %s: %v -> %v", req.BoxID, req.LocalPorts, req.RemotePorts), + "data": forward, + }) +} + +// 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 + } + + var req struct { + BoxID string `json:"box_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // If no body, stop all forwards + 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 + } + + // Stop port forwarding for the specific box + log.Printf("Stopping ADB port forward for box %s", req.BoxID) + 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{ + "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": fmt.Sprintf("ADB port expose stopped for box %s", req.BoxID), + }) +} + +// HandleADBExposeStatus handles ADB expose status requests +func (h *ADBExposeHandlers) HandleADBExposeStatus(w http.ResponseWriter, r *http.Request) { + status := map[string]interface{}{ + "running": true, // Always running as part of main server + } + + // Get box port forwards + forwards := h.listPortForwards() + status["forwards"] = forwards + + RespondJSON(w, http.StatusOK, status) +} + +// HandleADBExposeList handles ADB expose list requests +func (h *ADBExposeHandlers) HandleADBExposeList(w http.ResponseWriter, r *http.Request) { + // Get port forwards directly from port manager + forwards := h.listPortForwards() + + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "forwards": forwards, + "count": len(forwards), + }) +} + +// startPortForward starts port forwarding for a box +func (h *ADBExposeHandlers) startPortForward(req StartRequest) (*PortForward, error) { + // Get or create WebSocket connection + client, err := h.getOrCreateConnection(req.BoxID, req.Config) + if err != nil { + return nil, fmt.Errorf("failed to get connection: %v", err) + } + + // 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 + 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 h.startLocalListener(forward, localPort, remotePort) + } + + forward.Status = "running" + return forward, nil +} + +// stopPortForward stops port forwarding for a box +func (h *ADBExposeHandlers) stopPortForward(boxID string) error { + h.portManager.mu.Lock() + defer h.portManager.mu.Unlock() + + forward, exists := h.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(h.portManager.forwards, boxID) + + return nil +} + +// listPortForwards returns all active port forwards +func (h *ADBExposeHandlers) listPortForwards() []*BoxPortForward { + h.portManager.mu.RLock() + defer h.portManager.mu.RUnlock() + + boxForwards := make([]*BoxPortForward, 0, len(h.portManager.forwards)) + for _, forward := range h.portManager.forwards { + boxForward := &BoxPortForward{ + BoxID: forward.BoxID, + LocalPorts: forward.LocalPorts, + RemotePorts: forward.RemotePorts, + Status: forward.Status, + StartedAt: forward.StartedAt, + Error: forward.Error, + } + boxForwards = append(boxForwards, boxForward) + } + + return boxForwards +} + +// getOrCreateConnection gets or creates a WebSocket connection for a box +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 := h.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 + h.connectionPool.mu.Lock() + delete(h.connectionPool.connections, boxID) + h.connectionPool.mu.Unlock() + }() + + h.connectionPool.connections[boxID] = client + return client, nil +} + +// startLocalListener starts a local listener for port forwarding +func (h *ADBExposeHandlers) 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() + + // 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 { + 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) + } + continue + } + + // Handle connection in goroutine + go adb_expose.HandleLocalConnWithClient(conn, forward.client, remotePort) + } +} diff --git a/packages/cli/internal/server/handlers/api.go b/packages/cli/internal/server/handlers/api.go new file mode 100644 index 00000000..57a10ef4 --- /dev/null +++ b/packages/cli/internal/server/handlers/api.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "net/http" + "os" + "time" +) + +// APIHandlers contains handlers for general API routes (health, status, server management) +type APIHandlers struct { + serverService ServerService +} + +// NewAPIHandlers creates a new API handlers instance +func NewAPIHandlers(serverSvc ServerService) *APIHandlers { + return &APIHandlers{ + serverService: 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 are now handled directly by DeviceHandlers in the router +// ADB Expose endpoints are now handled directly by ADBExposeHandlers in the ADBExposeRouter + +// 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") + + // Set version headers for client verification + w.Header().Set("X-GBOX-Version", h.serverService.GetVersion()) + w.Header().Set("X-GBOX-Build-ID", h.serverService.GetBuildID()) + + 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..5c7b26fc --- /dev/null +++ b/packages/cli/internal/server/handlers/devices.go @@ -0,0 +1,2459 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/cloud" + "github.com/babelcloud/gbox/packages/cli/internal/device" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/control" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/audio" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/h264" + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/transport/stream" + serverScripts "github.com/babelcloud/gbox/packages/cli/internal/server/scripts" + "github.com/babelcloud/gbox/packages/cli/internal/util" + "github.com/gorilla/websocket" + "github.com/pkg/errors" +) + +// pathParam retrieves a path parameter from the request context +// This is a local copy to avoid import cycle with router package +func pathParam(r *http.Request, key string) string { + // Use the same context key format as PatternRouter + contextKey := "gbox-pattern-router:" + key + if val := r.Context().Value(contextKey); val != nil { + if str, ok := val.(string); ok { + return str + } + } + return "" +} + +// DeviceDTO is a strong-typed representation of a device for API responses +type DeviceDTO struct { + ID string `json:"id"` + TransportID string `json:"transportId"` + Serialno string `json:"serialno"` + Platform string `json:"platform"` // mobile, desktop + OS string `json:"os"` // android, linux, windows, macos + DeviceType string `json:"deviceType"` // physical, emulator, vm + IsRegistered bool `json:"isRegistered"` + IsConnected bool `json:"isConnected"` // true if device is currently connected to AP + IsReconnecting bool `json:"isReconnecting"` // true if device is attempting to reconnect + ReconnectAttempt int `json:"reconnectAttempt"` // Current reconnection attempt count + ReconnectMaxRetry int `json:"reconnectMaxRetry"` // Maximum reconnection attempts + RegId string `json:"regId"` + IsLocal bool `json:"isLocal"` // true if this is the local desktop device + Metadata map[string]interface{} `json:"metadata"` // Device-specific metadata +} + +// setWebMStreamingHeaders sets HTTP headers for WebM audio streaming +func setWebMStreamingHeaders(w http.ResponseWriter) { + w.Header().Set("Content-Type", "audio/webm; codecs=opus") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Transfer-Encoding", "chunked") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Range") +} + +// setRawOpusStreamingHeaders sets HTTP headers for raw Opus audio streaming +func setRawOpusStreamingHeaders(w http.ResponseWriter) { + w.Header().Set("Content-Type", "audio/opus") + w.Header().Set("Transfer-Encoding", "chunked") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") +} + +// startStreamingResponse sets headers and starts the streaming response +func startStreamingResponse(w http.ResponseWriter) { + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +var controlUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins for now + }, +} + +// DeviceHandlers contains handlers for device management +type DeviceHandlers struct { + serverService ServerService + upgrader websocket.Upgrader + webrtcHandlers *WebRTCHandlers + deviceManager device.DeviceManager +} + +// NewDeviceHandlers creates a new device handlers instance +func NewDeviceHandlers(serverSvc ServerService) *DeviceHandlers { + return &DeviceHandlers{ + serverService: serverSvc, + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins for now + }, + }, + webrtcHandlers: NewWebRTCHandlers(serverSvc), + deviceManager: device.NewManager("android"), + } +} + +// HandleDeviceList handles device listing requests +func (h *DeviceHandlers) HandleDeviceList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Use unified device manager (reused) + devs, err := h.deviceManager.GetDevices() + if err != nil { + log.Printf("Failed to get devices: %v", err) + RespondJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "success": false, + "error": err.Error(), + "devices": []interface{}{}, + }) + return + } + deviceAPI := cloud.NewDeviceAPI() + + // Get all registered devices from cloud in one call + registeredDevicesMap := make(map[string]*cloud.Device) + allCloudDevices, err := deviceAPI.GetAll() + if err != nil { + log.Printf("Failed to get all devices from cloud: %v", err) + } else { + // Build a map of regId -> Device for quick lookup + for _, cloudDevice := range allCloudDevices.Data { + if cloudDevice.RegId != "" { + registeredDevicesMap[cloudDevice.RegId] = cloudDevice + } + } + } + + dtos := make([]DeviceDTO, 0, len(devs)) + + // Add Android devices + for _, d := range devs { + // Get device information for Android device + androidMgr := device.NewManager("android") + width, height, resErr := androidMgr.GetDisplayResolution(d.ID) + displayResolution := "" + if resErr == nil { + displayResolution = fmt.Sprintf("%dx%d", width, height) + } + + osVersion, _ := androidMgr.GetOSVersion(d.ID) // non-fatal + memory, _ := androidMgr.GetMemory(d.ID) // non-fatal + + // Build Android-specific metadata + metadata := make(map[string]interface{}) + metadata["model"] = d.Model + metadata["manufacturer"] = d.Manufacturer + metadata["connectionType"] = d.ConnectionType + metadata["androidId"] = d.AndroidID // Android-specific field + if displayResolution != "" { + metadata["resolution"] = displayResolution + } + if osVersion != "" { + metadata["osVersion"] = osVersion + } + if memory != "" { + metadata["memory"] = memory + } + + dto := DeviceDTO{ + ID: "", + TransportID: d.ID, + Serialno: d.SerialNo, + Platform: "mobile", // Android devices are mobile + OS: "android", // Android devices + DeviceType: util.DetectAndroidDeviceType(d.ID, d.SerialNo), + IsRegistered: false, + RegId: d.RegId, + Metadata: metadata, + } + + // Check if device is registered by looking up in the map + if strings.TrimSpace(d.RegId) != "" { + if cloudDevice, found := registeredDevicesMap[d.RegId]; found { + dto.IsRegistered = true + dto.ID = cloudDevice.Id + // Update Platform and OS from cloud device metadata if available + if cloudDevice.Metadata.DeviceType != "" { + dto.Platform = cloudDevice.Metadata.DeviceType + } + if cloudDevice.Metadata.OsType != "" { + dto.OS = cloudDevice.Metadata.OsType + } + } + } + + // Check if device is currently connected to AP + dto.IsConnected = h.serverService.IsDeviceConnected(d.SerialNo) + + // Check reconnection state + if reconnectState := h.serverService.GetDeviceReconnectState(d.SerialNo); reconnectState != nil { + // Use type assertion with interface{} to avoid circular dependency + if stateMap, ok := reconnectState.(map[string]interface{}); ok { + if isReconnecting, ok := stateMap["isReconnecting"].(bool); ok { + dto.IsReconnecting = isReconnecting + } + if attempt, ok := stateMap["attempt"].(int); ok { + dto.ReconnectAttempt = attempt + } + if maxRetry, ok := stateMap["maxRetry"].(int); ok { + dto.ReconnectMaxRetry = maxRetry + } + } + } + + // Update device info cache with complete device information + h.serverService.UpdateDeviceInfo(&dto) + + dtos = append(dtos, dto) + } + + // Add desktop device (always show local machine) + // Map runtime.GOOS to osType for device manager + var osType string + switch runtime.GOOS { + case "darwin": + osType = "macos" + case "linux", "windows": + osType = strings.ToLower(runtime.GOOS) + default: + osType = "linux" // Default fallback + } + + desktopMgr := device.NewManager(osType) + localRegId, _ := desktopMgr.GetRegId("") // non-fatal + serialno := util.GetDesktopSerialNo(osType) + + // Get device information for desktop device + width, height, resErr := desktopMgr.GetDisplayResolution("") + displayResolution := "" + if resErr == nil { + displayResolution = fmt.Sprintf("%dx%d", width, height) + } + + osVersion, _ := desktopMgr.GetOSVersion("") // non-fatal + memory, _ := desktopMgr.GetMemory("") // non-fatal + + // Build desktop-specific metadata + metadata := make(map[string]interface{}) + if osType == "macos" { + metadata["chip"] = util.GetMacOSChip() // macOS-specific field + } + if osVersion != "" { + metadata["osVersion"] = osVersion + } + if memory != "" { + metadata["memory"] = memory + } + if displayResolution != "" { + metadata["resolution"] = displayResolution + } + // Add hostname for desktop devices + hostname, err := os.Hostname() + if err == nil && hostname != "" { + metadata["hostname"] = hostname + } + + var desktopDTO DeviceDTO + deviceType := util.DetectDesktopDeviceType(osType) + + if err == nil && localRegId != "" { + // Check if this desktop device is registered in cloud + if cloudDevice, found := registeredDevicesMap[localRegId]; found { + // Desktop device is registered + desktopDTO = DeviceDTO{ + ID: cloudDevice.Id, + TransportID: "local", + Serialno: cloudDevice.Metadata.Serialno, + Platform: "desktop", + OS: osType, + DeviceType: deviceType, + IsRegistered: true, + RegId: localRegId, + IsLocal: true, + Metadata: metadata, + } + // Update Platform and OS from cloud device metadata if available + if cloudDevice.Metadata.DeviceType != "" { + desktopDTO.Platform = cloudDevice.Metadata.DeviceType + } + if cloudDevice.Metadata.OsType != "" { + desktopDTO.OS = cloudDevice.Metadata.OsType + } + } else { + // Desktop device exists locally but not registered + desktopDTO = DeviceDTO{ + ID: "", + TransportID: "local", + Serialno: serialno, + Platform: "desktop", + OS: osType, + DeviceType: deviceType, + IsRegistered: false, + RegId: localRegId, + IsLocal: true, + Metadata: metadata, + } + } + } else { + // No local regId, show desktop device as unregistered + desktopDTO = DeviceDTO{ + ID: "", + TransportID: "local", + Serialno: serialno, + Platform: "desktop", + OS: osType, + DeviceType: deviceType, + IsRegistered: false, + RegId: "", + IsLocal: true, + Metadata: metadata, + } + } + + // Check if desktop device is currently connected to AP + desktopDTO.IsConnected = h.serverService.IsDeviceConnected(desktopDTO.Serialno) + + // Check reconnection state for desktop device + if reconnectState := h.serverService.GetDeviceReconnectState(desktopDTO.Serialno); reconnectState != nil { + if stateMap, ok := reconnectState.(map[string]interface{}); ok { + if isReconnecting, ok := stateMap["isReconnecting"].(bool); ok { + desktopDTO.IsReconnecting = isReconnecting + } + if attempt, ok := stateMap["attempt"].(int); ok { + desktopDTO.ReconnectAttempt = attempt + } + if maxRetry, ok := stateMap["maxRetry"].(int); ok { + desktopDTO.ReconnectMaxRetry = maxRetry + } + } + } + + // Update device info cache with complete desktop device information + h.serverService.UpdateDeviceInfo(&desktopDTO) + + dtos = append(dtos, desktopDTO) + + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "devices": dtos, + "onDemandEnabled": true, + }) +} + +// HandleDeviceAction handles device action requests (connect/disconnect) +func (h *DeviceHandlers) HandleDeviceAction(w http.ResponseWriter, r *http.Request) { + // Extract device serial from path: /api/devices/{serial} + path := strings.TrimPrefix(r.URL.Path, "/api/devices/") + deviceID := strings.Split(path, "/")[0] + + if deviceID == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + // Handle connect/disconnect based on HTTP method + switch r.Method { + case http.MethodPost: + // POST /api/devices/{serial} - connect device + h.handleDeviceConnect(w, r, deviceID) + case http.MethodDelete: + // DELETE /api/devices/{serial} - disconnect device + h.handleDeviceDisconnect(w, r, deviceID) + default: + RespondJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{ + "success": false, + "error": "Method not allowed. Use POST to connect or DELETE to disconnect", + }) + } +} + +// validateDeviceTypeAndOsType validates deviceType and osType parameters and their combination. +// Returns normalized osType (with default value applied) and error if validation fails. +func validateDeviceTypeAndOsType(deviceType, osType string) (string, error) { + // Validate deviceType + if deviceType != "mobile" && deviceType != "desktop" { + return "", fmt.Errorf("invalid deviceType: %s, must be 'mobile' or 'desktop'", deviceType) + } + + // Validate osType based on deviceType + switch deviceType { + case "mobile": + // Mobile devices only support android + if osType != "" && osType != "android" { + return "", fmt.Errorf("mobile device type only supports 'android' osType, got: %s", osType) + } + // Default to android if not specified + if osType == "" { + osType = "android" + } + case "desktop": + // Desktop devices support linux, windows, macos + validDesktopOsTypes := map[string]bool{ + "linux": true, + "windows": true, + "macos": true, + } + if osType != "" && !validDesktopOsTypes[osType] { + return "", fmt.Errorf("desktop device type only supports 'linux', 'windows', or 'macos' osType, got: %s", osType) + } + default: + return "", fmt.Errorf("invalid deviceType: %s, must be 'mobile' or 'desktop'", deviceType) + } + + return osType, nil +} + +// HandleDeviceRegister handles device registration requests +func (h *DeviceHandlers) HandleDeviceRegister(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var reqBody struct { + DeviceId string `json:"deviceId"` + DeviceType string `json:"deviceType"` // mobile, desktop + OsType string `json:"osType"` // android, linux, windows, macos + } + if err := decoder.Decode(&reqBody); err != nil { + http.Error(w, errors.Wrap(err, "failed to parse request body").Error(), http.StatusBadRequest) + return + } + + // Normalize deviceType and osType + deviceType := strings.ToLower(reqBody.DeviceType) + osType := strings.ToLower(reqBody.OsType) + + // Validate deviceType and osType, and get normalized osType + normalizedOsType, err := validateDeviceTypeAndOsType(deviceType, osType) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + osType = normalizedOsType + + deviceAPI := cloud.NewDeviceAPI() + created, err := h.registerDevice(deviceAPI, reqBody.DeviceId, deviceType, osType) + if err != nil { + // Check if it's a validation error (400) or server error (500) + if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "only supports") { + http.Error(w, err.Error(), http.StatusBadRequest) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + // Connect to access point asynchronously (for both desktop and mobile) + go func() { + if err := h.serverService.ConnectAP(created.Id); err != nil { + log.Print(errors.Wrapf(err, "fail to connect device %s to access point", created.Id)) + } + }() + + // Return response + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "data": created, + }) +} + +// registerDevice registers a device based on deviceType and osType +func (h *DeviceHandlers) registerDevice(deviceAPI *cloud.DeviceAPI, deviceId, deviceType, osType string) (*cloud.Device, error) { + // Initialize xdotool and noVNC environments for Linux desktop devices + if deviceType == "desktop" && osType == "linux" { + if err := runLinuxInitXdotoolScript(); err != nil { + log.Printf("Warning: init-linux-xdotool script failed: %v", err) + } + if err := runLinuxInitNoVncScript(); err != nil { + log.Printf("Warning: init-linux-novnc script failed: %v", err) + } + } + + // Prepare device metadata based on device type + var serialno, androidId, regId string + var err error + metadata := make(map[string]interface{}) + + if deviceType == "desktop" { + // Desktop device: get serialno from system + serialno = util.GetDesktopSerialNo(osType) + // Try to read existing regId from local file using DesktopManager + desktopMgr := device.NewManager(osType) + if regIdFromFile, readErr := desktopMgr.GetRegId(""); readErr == nil && regIdFromFile != "" { + regId = regIdFromFile + } + + // Get device information for desktop device + width, height, resErr := desktopMgr.GetDisplayResolution("") + displayResolution := "" + if resErr == nil { + displayResolution = fmt.Sprintf("%dx%d", width, height) + } + + osVersion, _ := desktopMgr.GetOSVersion("") // non-fatal + memory, _ := desktopMgr.GetMemory("") // non-fatal + + // Build desktop-specific metadata + if osType == "macos" { + metadata["chip"] = util.GetMacOSChip() // macOS-specific field + } + if osVersion != "" { + metadata["osVersion"] = osVersion + } + if memory != "" { + metadata["memory"] = memory + } + if displayResolution != "" { + metadata["resolution"] = displayResolution + } + // Add hostname for desktop devices + hostname, hostnameErr := os.Hostname() + if hostnameErr == nil && hostname != "" { + metadata["hostname"] = hostname + } + } else { + // Mobile device: get identifiers from ADB + devMgr := device.NewManager("android") + ids, err := devMgr.GetIdentifiers(deviceId) + if err != nil { + return nil, errors.Wrap(err, "failed to resolve device serialno or android_id") + } + serialno = ids.SerialNo + if ids.AndroidID != nil { + androidId = *ids.AndroidID + metadata["androidId"] = androidId + } + regId = ids.RegId + + // Get device information for Android device + width, height, resErr := devMgr.GetDisplayResolution(deviceId) + displayResolution := "" + if resErr == nil { + displayResolution = fmt.Sprintf("%dx%d", width, height) + } + + osVersion, _ := devMgr.GetOSVersion(deviceId) // non-fatal + memory, _ := devMgr.GetMemory(deviceId) // non-fatal + + // Get device info to get model, manufacturer, connectionType + devices, err := devMgr.GetDevices() + if err == nil { + for _, d := range devices { + if d.ID == deviceId { + if d.Model != "" { + metadata["model"] = d.Model + } + if d.Manufacturer != "" { + metadata["manufacturer"] = d.Manufacturer + } + if d.ConnectionType != "" { + metadata["connectionType"] = d.ConnectionType + } + break + } + } + } + + // Build Android-specific metadata + if displayResolution != "" { + metadata["resolution"] = displayResolution + } + if osVersion != "" { + metadata["osVersion"] = osVersion + } + if memory != "" { + metadata["memory"] = memory + } + } + + // Extract fields from metadata map + resolution := "" + if res, ok := metadata["resolution"].(string); ok { + resolution = res + } + hostname := "" + if hn, ok := metadata["hostname"].(string); ok { + hostname = hn + } + chip := "" + if c, ok := metadata["chip"].(string); ok { + chip = c + } + osVersion := "" + if ov, ok := metadata["osVersion"].(string); ok { + osVersion = ov + } + memory := "" + if m, ok := metadata["memory"].(string); ok { + memory = m + } + model := "" + if m, ok := metadata["model"].(string); ok { + model = m + } + manufacturer := "" + if m, ok := metadata["manufacturer"].(string); ok { + manufacturer = m + } + connectionType := "" + if ct, ok := metadata["connectionType"].(string); ok { + connectionType = ct + } + + // Create device in cloud + newDevice := &cloud.Device{ + Metadata: struct { + Serialno string `json:"serialno,omitempty"` + AndroidId string `json:"androidId,omitempty"` + Type string `json:"type,omitempty"` + DeviceType string `json:"deviceType,omitempty"` + OsType string `json:"osType,omitempty"` + Resolution string `json:"resolution,omitempty"` + Hostname string `json:"hostname,omitempty"` + Chip string `json:"chip,omitempty"` + OsVersion string `json:"osVersion,omitempty"` + Memory string `json:"memory,omitempty"` + Model string `json:"model,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + ConnectionType string `json:"connectionType,omitempty"` + }{ + Serialno: serialno, + AndroidId: androidId, + Type: osType, // Set Type field for backward compatibility with remote API + DeviceType: deviceType, + OsType: osType, + Resolution: resolution, + Hostname: hostname, + Chip: chip, + OsVersion: osVersion, + Memory: memory, + Model: model, + Manufacturer: manufacturer, + ConnectionType: connectionType, + }, + RegId: regId, + } + + created, err := deviceAPI.Create(newDevice) + if err != nil { + return nil, errors.Wrap(err, "failed to register device") + } + + // Persist the created device RegId back to the device + if created != nil && created.RegId != "" { + var devMgr device.DeviceManager + if deviceType == "desktop" { + devMgr = device.NewManager(osType) + } else { + devMgr = device.NewManager("android") + } + if err := devMgr.SetRegId(deviceId, created.RegId); err != nil { + log.Printf("Warning: failed to persist RegId to device %s: %v", deviceId, err) + } + } + + return created, nil +} + +// HandleDeviceUnregister handles device unregistration requests +func (h *DeviceHandlers) HandleDeviceUnregister(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var reqBody struct { + DeviceId string `json:"deviceId"` + } + if err := decoder.Decode(&reqBody); err != nil { + http.Error(w, errors.Wrap(err, "failed to parse request body").Error(), http.StatusBadRequest) + return + } + + deviceAPI := cloud.NewDeviceAPI() + + // First, try to find device by regId (works for both Android and Desktop devices) + // If reqBody.DeviceId looks like a UUID/regId, try to find device by regId first + deviceList, err := deviceAPI.GetByRegId(reqBody.DeviceId) + if err == nil && len(deviceList.Data) > 0 { + // Found device(s) by regId, delete them + for _, device := range deviceList.Data { + if err := deviceAPI.Delete(device.Id); err != nil { + http.Error(w, errors.Wrapf(err, "failed to delete device %s", device.Id).Error(), http.StatusInternalServerError) + return + } + } + // Successfully deleted by regId, return early + go func() { + if err := h.serverService.DisconnectAP(reqBody.DeviceId); err != nil { + log.Print(errors.Wrapf(err, "fail to disconnect device %s from access point", reqBody.DeviceId)) + } + }() + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) + return + } + + // If not found by regId, try to resolve as Android device (for backward compatibility) + devMgr2 := device.NewManager("android") + ids2, err := devMgr2.GetIdentifiers(reqBody.DeviceId) + if err != nil { + // If GetIdentifiers fails, it might be a desktop device or invalid deviceId + // Try one more time to find by regId (in case it's a regId that wasn't found above) + deviceList, err2 := deviceAPI.GetByRegId(reqBody.DeviceId) + if err2 == nil && len(deviceList.Data) > 0 { + for _, device := range deviceList.Data { + if err := deviceAPI.Delete(device.Id); err != nil { + http.Error(w, errors.Wrapf(err, "failed to delete device %s", device.Id).Error(), http.StatusInternalServerError) + return + } + } + go func() { + if err := h.serverService.DisconnectAP(reqBody.DeviceId); err != nil { + log.Print(errors.Wrapf(err, "fail to disconnect device %s from access point", reqBody.DeviceId)) + } + }() + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) + return + } + http.Error(w, errors.Wrap(err, "failed to resolve device identifiers").Error(), http.StatusInternalServerError) + return + } + + serialno := ids2.SerialNo + var androidId string + if ids2.AndroidID != nil { + androidId = *ids2.AndroidID + } + regId := ids2.RegId + + // If regId is available, use it to find and delete the device (most accurate) + if regId != "" { + deviceList, err := deviceAPI.GetByRegId(regId) + if err != nil { + // If lookup by regId fails, fallback to serialno/androidId lookup + log.Printf("Warning: failed to get devices by regId %s: %v, falling back to serialno/androidId lookup", regId, err) + } else if len(deviceList.Data) > 0 { + // Found device(s) by regId, delete them + for _, device := range deviceList.Data { + if err := deviceAPI.Delete(device.Id); err != nil { + http.Error(w, errors.Wrapf(err, "failed to delete device %s", device.Id).Error(), http.StatusInternalServerError) + return + } + } + // Successfully deleted by regId, return early + go func() { + if err := h.serverService.DisconnectAP(reqBody.DeviceId); err != nil { + log.Print(errors.Wrapf(err, "fail to disconnect device %s from access point", reqBody.DeviceId)) + } + }() + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) + return + } + } + + // Fallback: use serialno and androidId to find and delete devices (Android only) + deviceList, err = deviceAPI.GetBySerialnoAndAndroidId(serialno, androidId) + if err != nil { + http.Error(w, errors.Wrap(err, "failed to get devices").Error(), http.StatusInternalServerError) + return + } + + if len(deviceList.Data) > 0 { + for _, device := range deviceList.Data { + if err := deviceAPI.Delete(device.Id); err != nil { + http.Error(w, errors.Wrapf(err, "failed to delete device %s", device.Id).Error(), http.StatusInternalServerError) + return + } + } + } else { + // No devices found by serialno/androidId either + if regId != "" { + http.Error(w, fmt.Errorf("device not found by regId %s or serialno/androidId", regId).Error(), http.StatusNotFound) + return + } + http.Error(w, fmt.Errorf("device not found").Error(), http.StatusNotFound) + return + } + + go func() { + if err := h.serverService.DisconnectAP(reqBody.DeviceId); err != nil { + log.Print(errors.Wrapf(err, "fail to disconnect device %s from access point", reqBody.DeviceId)) + } + }() + + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + }) +} + +// Device streaming handlers +func (h *DeviceHandlers) HandleDeviceVideo(w http.ResponseWriter, req *http.Request) { + // Extract device serial from path: /api/devices/{serial}/video + path := strings.TrimPrefix(req.URL.Path, "/api/devices/") + parts := strings.Split(path, "/") + deviceSerial := parts[0] + + if strings.Contains(req.Header.Get("via"), "gbox-device-ap") { + deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial) + } + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + // Parse mode and format parameters + query := req.URL.Query() + codec := query.Get("codec") + format := query.Get("format") + mode := query.Get("mode") // Check if mode is already provided + + // Set mode based on codec and format, or use existing mode + if mode == "" { + if codec == "h264" { + if format == "avc" { + mode = "h264" + format = "avc" + } else if format == "annexb" { + mode = "h264" + format = "annexb" + } else if format == "webm" { + mode = "webm" + } else { + mode = "h264" // default to annexb + } + } else { + mode = "h264" // default + } + } + + log.Printf("[HandleDeviceVideo] Processing video request for device: %s, mode: %s, format: %s", deviceSerial, mode, format) + + // Direct video streaming implementation + switch mode { + case "h264": + // Check format parameter for AVC vs Annex-B + if format == "avc" { + // AVC format H.264 streaming (for WebCodecs) + handler := h264.NewAVCHTTPHandler(deviceSerial) + handler.ServeHTTP(w, req) + } else { + // Direct H.264 streaming (Annex-B format) + handler := h264.NewAnnexBHandler(deviceSerial) + handler.ServeHTTP(w, req) + } + + case "webm": + // WebM container streaming with H.264 video and Opus audio + h.HandleWebMStream(w, req, deviceSerial) + + case "mp4": + // MP4 container streaming with H.264 video and Opus audio + h.HandleMP4Stream(w, req, deviceSerial) + + default: + http.Error(w, "Invalid mode. Supported: h264, webm, mp4", http.StatusBadRequest) + } +} + +func (h *DeviceHandlers) HandleDeviceAudio(w http.ResponseWriter, req *http.Request) { + // Extract device serial from path: /api/devices/{serial}/audio + path := strings.TrimPrefix(req.URL.Path, "/api/devices/") + parts := strings.Split(path, "/") + deviceSerial := parts[0] + + log.Printf("[HandleDeviceAudio] Processing audio request for device: %s, URL: %s", deviceSerial, req.URL.String()) + + if strings.Contains(req.Header.Get("via"), "gbox-device-ap") { + deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial) + } + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + if !isValidDeviceSerial(deviceSerial) { + http.Error(w, "Invalid device serial", http.StatusBadRequest) + return + } + + // Parse codec/format parameters + codec := req.URL.Query().Get("codec") + if codec == "" { + codec = "aac" // default to AAC + } + format := req.URL.Query().Get("format") + + log.Printf("[HandleDeviceAudio] Audio parameters - codec: %s, format: %s", codec, format) + + // Direct audio streaming implementation + switch codec { + case "opus": + // Determine streaming format and setup + audioService := audio.GetAudioService() + if format == "webm" { + // WebM container streaming + log.Printf("[HandleDeviceAudio] Using WebM streaming for device: %s", deviceSerial) + setWebMStreamingHeaders(w) + startStreamingResponse(w) + if err := audioService.StreamWebM(deviceSerial, w, req); err != nil { + log.Printf("[HandleDeviceAudio] WebM streaming error: %v", err) + http.Error(w, "WebM streaming failed", http.StatusInternalServerError) + } + return + } + // Raw Opus streaming + log.Printf("[HandleDeviceAudio] Using raw Opus streaming for device: %s", deviceSerial) + setRawOpusStreamingHeaders(w) + startStreamingResponse(w) + audioService.StreamOpus(deviceSerial, w) + + case "aac": + // AAC dump: format=raw (default) or format=adts + withADTS := strings.ToLower(format) == "adts" + + // Ensure scrcpy source in mp4 mode to use AAC encoder + // This is handled by the audio service + log.Printf("[HandleDeviceAudio] Using AAC streaming for device: %s, withADTS: %v", deviceSerial, withADTS) + + // Set appropriate headers for AAC streaming + if withADTS { + w.Header().Set("Content-Type", "audio/aac") + } else { + w.Header().Set("Content-Type", "audio/aac") + } + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + startStreamingResponse(w) + // Note: AAC streaming is not implemented in the current audio service + // This would need to be implemented if AAC streaming is required + http.Error(w, "AAC streaming not implemented", http.StatusNotImplemented) + + default: + http.Error(w, "Invalid codec. Supported: opus, aac", http.StatusBadRequest) + } +} + +func (h *DeviceHandlers) HandleDeviceStream(w http.ResponseWriter, req *http.Request) { + // Extract device serial from path: /api/devices/{serial}/stream + path := strings.TrimPrefix(req.URL.Path, "/api/devices/") + parts := strings.Split(path, "/") + deviceSerial := parts[0] + + if strings.Contains(req.Header.Get("via"), "gbox-device-ap") { + deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial) + } + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + log.Printf("[HandleDeviceStream] Processing stream request for device: %s", deviceSerial) + + // Parse query parameters + codec := req.URL.Query().Get("codec") + if codec == "" { + codec = "h264+aac" + } + format := req.URL.Query().Get("format") + if format == "" { + format = "mp4" + } + + log.Printf("[HandleDeviceStream] Parameters - codec: %s, format: %s", codec, format) + + // Validate parameters - Go's url.Query().Get() automatically decodes URL encoding + if codec != "h264+opus" && codec != "h264+aac" { + http.Error(w, "Invalid codec. Only 'h264+opus' and 'h264+aac' are supported for mixed streams", http.StatusBadRequest) + return + } + + if format != "webm" && format != "mp4" { + http.Error(w, "Invalid format. Only 'webm' and 'mp4' are supported for mixed streams", http.StatusBadRequest) + return + } + + // Direct mixed stream implementation + log.Printf("[HandleDeviceStream] Using %s format for mixed stream", format) + + switch format { + case "webm": + // WebM container streaming with H.264 video and Opus audio + h.HandleWebMStream(w, req, deviceSerial) + + case "mp4": + // MP4 container streaming with H.264 video and AAC audio + h.HandleMP4Stream(w, req, deviceSerial) + + default: + http.Error(w, "Invalid format. Supported: webm, mp4", http.StatusBadRequest) + } +} + +func (h *DeviceHandlers) HandleDeviceControl(w http.ResponseWriter, req *http.Request) { + // Extract device serial from path: /api/devices/{serial}/control + path := strings.TrimPrefix(req.URL.Path, "/api/devices/") + parts := strings.Split(path, "/") + deviceSerial := parts[0] + + if strings.Contains(req.Header.Get("via"), "gbox-device-ap") { + deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial) + } + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + if !isValidDeviceSerial(deviceSerial) { + http.Error(w, "Invalid device serial", http.StatusBadRequest) + return + } + + log.Printf("[HandleDeviceControl] Processing control WebSocket request for device: %s", deviceSerial) + + // Direct control WebSocket implementation + conn, err := controlUpgrader.Upgrade(w, req, nil) + if err != nil { + log.Printf("[HandleDeviceControl] Failed to upgrade control WebSocket: %v", err) + return + } + defer conn.Close() + + log.Printf("[HandleDeviceControl] Control WebSocket connection established for device: %s", deviceSerial) + + // Delegate to control service + controlService := control.GetControlService() + + // Handle WebSocket messages + for { + var msg map[string]interface{} + if err := conn.ReadJSON(&msg); err != nil { + // Check for normal close conditions + if websocket.IsCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { + log.Printf("[HandleDeviceControl] Control WebSocket closed normally for device: %s", deviceSerial) + } else if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("[HandleDeviceControl] Control WebSocket read error for device: %s: %v", deviceSerial, err) + } + break + } + + msgType, ok := msg["type"].(string) + if !ok { + continue + } + + slog.Debug("Control message received", "device", deviceSerial, "type", msgType) + + switch msgType { + // WebRTC signaling messages + case "ping", "offer", "answer", "ice-candidate": + h.handleWebRTCMessage(conn, msg, msgType, deviceSerial) + + // Device control messages + case "key": + // Handle key events + controlService.HandleKeyEvent(msg, deviceSerial) + + case "text": + // Handle text input (clipboard event) + clipboardMsg := map[string]interface{}{ + "text": msg["text"], + "paste": true, + } + controlService.HandleClipboardEvent(clipboardMsg, deviceSerial) + + case "touch": + // Handle touch events + controlService.HandleTouchEvent(msg, deviceSerial) + + case "scroll": + // Handle scroll events + controlService.HandleScrollEvent(msg, deviceSerial) + + case "clipboard_set": + controlService.HandleClipboardEvent(msg, deviceSerial) + + case "reset_video": + controlService.HandleVideoResetEvent(msg, deviceSerial) + + default: + log.Printf("[HandleDeviceControl] Unknown message type for device: %s: %s", deviceSerial, msgType) + } + } +} + +// HandleDeviceExec executes a shell command on the server, scoped under a device path +// Path: /api/devices/{serial}/exec +// Method: POST +// Body JSON: { "cmd": "echo hello", "timeoutSec": 60 } +// Response JSON: { stdout, stderr, exitCode, durationMs } +func (h *DeviceHandlers) HandleDeviceExec(w http.ResponseWriter, req *http.Request) { + // Extract device serial from path + path := strings.TrimPrefix(req.URL.Path, "/api/devices/") + parts := strings.Split(path, "/") + deviceSerial := parts[0] + + if strings.Contains(req.Header.Get("via"), "gbox-device-ap") { + deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial) + } + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + if req.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var payload struct { + Cmd string `json:"cmd"` + TimeoutSec int `json:"timeoutSec"` + WorkingDir string `json:"workingDir"` + Envs map[string]string `json:"envs"` + } + decoder := json.NewDecoder(req.Body) + if err := decoder.Decode(&payload); err != nil { + http.Error(w, "Invalid JSON body", http.StatusBadRequest) + return + } + if payload.Cmd == "" { + http.Error(w, "Field 'cmd' is required", http.StatusBadRequest) + return + } + if payload.TimeoutSec <= 0 { + payload.TimeoutSec = 60 + } + + ctx, cancel := context.WithTimeout(req.Context(), time.Duration(payload.TimeoutSec)*time.Second) + defer cancel() + + // Determine device type by looking up device information + devicePlatform := h.getDevicePlatform(deviceSerial) + + // Set default workingDir based on platform + workingDir := payload.WorkingDir + if workingDir == "" { + if devicePlatform == "mobile" { + workingDir = "/data/local/tmp" + } else { + workingDir = "/" + } + } + + var cmd *exec.Cmd + if devicePlatform == "mobile" { + // Execute command on Android device via adb shell + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + + // Build command with environment variables if provided + shellCmd := payload.Cmd + if len(payload.Envs) > 0 { + envVars := "" + for k, v := range payload.Envs { + envVars += fmt.Sprintf("export %s=%s; ", k, v) + } + shellCmd = envVars + shellCmd + } + + // Set working directory and execute command + fullCmd := fmt.Sprintf("cd %s && %s", workingDir, shellCmd) + cmd = exec.CommandContext(ctx, adbPath, "-s", deviceSerial, "shell", fullCmd) + } else { + // Execute command locally on desktop device + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(ctx, "cmd", "/C", payload.Cmd) + } else { + cmd = exec.CommandContext(ctx, "/bin/sh", "-c", payload.Cmd) + } + + // Set working directory + cmd.Dir = workingDir + + // Set environment variables + if len(payload.Envs) > 0 { + cmd.Env = os.Environ() + for k, v := range payload.Envs { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) + } + } + } + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + start := time.Now() + runErr := cmd.Run() + duration := time.Since(start) + + exitCode := 0 + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + } + + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "device": deviceSerial, + "stdout": stdoutBuf.String(), + "stderr": stderrBuf.String(), + "exitCode": exitCode, + "durationMs": duration.Milliseconds(), + }) +} + +// getDevicePlatform determines the platform type (mobile or desktop) for a given device serial +// Returns "mobile" for Android devices, "desktop" for desktop devices +func (h *DeviceHandlers) getDevicePlatform(deviceSerial string) string { + // First, try to get from device keeper cache (supports serialno, deviceId, or regId lookup) + deviceInfo := h.serverService.GetDeviceInfo(deviceSerial) + if deviceInfo != nil { + if dto, ok := deviceInfo.(*DeviceDTO); ok && dto.Platform != "" { + return dto.Platform + } + } + + // If not in cache, try to determine from local device list + // This uses local API (not remote API) to get device information + dtos := h.getLocalDeviceList() + for _, dto := range dtos { + // Match by serialno, TransportID, or ID + if dto.Serialno == deviceSerial || dto.TransportID == deviceSerial || dto.ID == deviceSerial { + platform := dto.Platform + if platform == "" { + // Fallback: determine from OS type + if dto.OS == "android" { + platform = "mobile" + } else { + platform = "desktop" + } + } + // Update cache with complete device info for future use + h.serverService.UpdateDeviceInfo(&dto) + return platform + } + } + + // Default to desktop if we can't determine (safer for local execution) + return "desktop" +} + +// getLocalDeviceList gets device list locally without making remote API calls +// This is a helper function to avoid duplicating HandleDeviceList logic +func (h *DeviceHandlers) getLocalDeviceList() []DeviceDTO { + devs, err := h.deviceManager.GetDevices() + if err != nil { + return []DeviceDTO{} + } + + dtos := make([]DeviceDTO, 0, len(devs)) + + // Add Android devices + for _, d := range devs { + dto := DeviceDTO{ + ID: "", + TransportID: d.ID, + Serialno: d.SerialNo, + Platform: "mobile", // Android devices are mobile + OS: "android", // Android devices + DeviceType: util.DetectAndroidDeviceType(d.ID, d.SerialNo), + IsRegistered: false, + RegId: d.RegId, + } + dtos = append(dtos, dto) + } + + // Add desktop device (always show local machine) + var osType string + switch runtime.GOOS { + case "darwin": + osType = "macos" + case "linux", "windows": + osType = strings.ToLower(runtime.GOOS) + default: + osType = "linux" + } + + desktopMgr := device.NewManager(osType) + localRegId, _ := desktopMgr.GetRegId("") // non-fatal + serialno := util.GetDesktopSerialNo(osType) + + desktopDTO := DeviceDTO{ + ID: "", + TransportID: "local", + Serialno: serialno, + Platform: "desktop", + OS: osType, + DeviceType: util.DetectDesktopDeviceType(osType), + IsRegistered: false, + RegId: localRegId, + IsLocal: true, + Metadata: make(map[string]interface{}), + } + + dtos = append(dtos, desktopDTO) + return dtos +} + +// handleWebRTCMessage handles WebRTC signaling messages +func (h *DeviceHandlers) handleWebRTCMessage(conn *websocket.Conn, msg map[string]interface{}, msgType, deviceSerial string) { + if h.webrtcHandlers == nil { + log.Printf("[HandleDeviceControl] WebRTC handlers not initialized") + return + } + + log.Printf("[HandleDeviceControl] Delegating WebRTC message to handler: type=%s, device=%s", msgType, deviceSerial) + + switch msgType { + case "ping": + h.webrtcHandlers.HandlePing(conn, msg) + case "offer": + h.webrtcHandlers.HandleOffer(conn, msg, deviceSerial) + case "answer": + h.webrtcHandlers.HandleAnswer(conn, msg, deviceSerial) + case "ice-candidate": + h.webrtcHandlers.HandleIceCandidate(conn, msg, deviceSerial) + default: + log.Printf("[HandleDeviceControl] Unknown WebRTC message type: %s", msgType) + } +} + +// HandleWebSocket handles WebSocket connections for device communication +func (h *DeviceHandlers) HandleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := h.upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket upgrade failed: %v", err) + return + } + defer conn.Close() + + log.Println("WebSocket connection established") + + for { + var msg map[string]interface{} + err := conn.ReadJSON(&msg) + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("WebSocket error: %v", err) + } + break + } + + // Handle different message types + msgType, ok := msg["type"].(string) + if !ok { + log.Printf("Invalid message format: missing type") + continue + } + + switch msgType { + case "connect": + h.handleWebSocketConnect(conn, msg) + case "offer": + h.handleWebSocketOffer(conn, msg) + case "ice-candidate": + h.handleWebSocketICECandidate(conn, msg) + case "disconnect": + h.handleWebSocketDisconnect(conn, msg) + default: + log.Printf("Unknown message type: %s", msgType) + } + } +} + +func (h *DeviceHandlers) HandleDeviceAdb(w http.ResponseWriter, req *http.Request) { + path := strings.TrimPrefix(req.URL.Path, "/api/devices/") + parts := strings.Split(path, "/") + deviceSerial := parts[0] + + if strings.Contains(req.Header.Get("via"), "gbox-device-ap") { + deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial) + } + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + if !isValidDeviceSerial(deviceSerial) { + http.Error(w, "Invalid device serial", http.StatusBadRequest) + return + } + + log.Printf("[HandleDeviceAdb] Processing adb request for device: %s", deviceSerial) + + decoder := json.NewDecoder(req.Body) + var reqBody struct { + Command string `json:"command"` + } + if err := decoder.Decode(&reqBody); err != nil { + http.Error(w, errors.Wrap(err, "failed to parse request body").Error(), http.StatusBadRequest) + return + } + + manager := device.NewManager("android") + // ExecAdbCommand is only available on AndroidManager, not DeviceManager interface + // Cast to *AndroidManager to access ExecAdbCommand + androidMgr, ok := manager.(*device.AndroidManager) + if !ok { + http.Error(w, "ExecAdbCommand is only available for Android devices", http.StatusBadRequest) + return + } + result, err := androidMgr.ExecAdbCommand(deviceSerial, reqBody.Command) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + RespondJSON(w, http.StatusOK, result) +} + +// HandleDeviceAppium proxies Appium requests to the local Appium server. +// Supports all HTTP methods and WebSocket upgrades required by Appium. +func (h *DeviceHandlers) HandleDeviceAppium(w http.ResponseWriter, req *http.Request) { + deviceSerial := pathParam(req, "serial") + originalSerial := deviceSerial + + if strings.Contains(req.Header.Get("via"), "gbox-device-ap") { + if resolved := h.serverService.GetSerialByDeviceId(deviceSerial); resolved != "" { + deviceSerial = resolved + } else { + log.Printf("[HandleDeviceAppium] Unable to resolve device serial for deviceId %q", deviceSerial) + } + } + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + if deviceSerial != "" && deviceSerial != originalSerial { + if rewritten := h.rewriteAppiumRequestBody(req, deviceSerial); rewritten { + log.Printf("[HandleDeviceAppium] Rewrote Appium request body to use serial %s", deviceSerial) + } + } + + appiumHost := os.Getenv("APPIUM_HOST") + if appiumHost == "" { + appiumHost = "127.0.0.1" + } + + appiumPort := os.Getenv("APPIUM_PORT") + if appiumPort == "" { + appiumPort = "4723" + } + + targetURL := &url.URL{ + Scheme: "http", + Host: fmt.Sprintf("%s:%s", appiumHost, appiumPort), + } + + restPath := pathParam(req, "path") + trimmed := strings.TrimPrefix(restPath, "/") + targetPath := "/" + if trimmed != "" { + targetPath = "/" + trimmed + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + originalDirector := proxy.Director + originalHeaders := req.Header.Clone() + originalQuery := req.URL.RawQuery + + proxy.Director = func(r *http.Request) { + // Preserve original headers before applying default director logic. + r.Header = originalHeaders.Clone() + + originalDirector(r) + + r.URL.Path = targetPath + r.URL.RawPath = targetPath + r.URL.RawQuery = originalQuery + r.Host = targetURL.Host + + r.Header.Set("X-GBOX-Device-Serial", deviceSerial) + r.Header.Set("X-GBOX-Forwarded-By", "gbox-server") + } + + proxy.ErrorHandler = func(rw http.ResponseWriter, r *http.Request, err error) { + log.Printf("[HandleDeviceAppium] proxy error for device %s: %v", deviceSerial, err) + http.Error(rw, fmt.Sprintf("Failed to proxy Appium request: %v", err), http.StatusBadGateway) + } + + log.Printf("[HandleDeviceAppium] Proxying %s %s to %s%s", req.Method, req.URL.Path, targetURL.Host, targetPath) + proxy.ServeHTTP(w, req) +} + +func (h *DeviceHandlers) rewriteAppiumRequestBody(req *http.Request, deviceSerial string) bool { + if req.Body == nil { + return false + } + + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + return false + } + defer req.Body.Close() + + if len(bodyBytes) == 0 { + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + return false + } + + newBody, updated := rewriteAppiumPayload(bodyBytes, deviceSerial) + if !updated { + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + return false + } + + req.Body = io.NopCloser(bytes.NewReader(newBody)) + req.ContentLength = int64(len(newBody)) + req.Header.Set("Content-Length", strconv.Itoa(len(newBody))) + + return true +} + +// HandleDeviceFiles handles file operations for devices +// Routes: +// - GET /api/devices/{serial}/files - read file +// - POST /api/devices/{serial}/files - write file +// - DELETE /api/devices/{serial}/files - delete file +// - GET /api/devices/{serial}/files/list - list files +// - GET /api/devices/{serial}/files/info - get file info +// - POST /api/devices/{serial}/files/rename - rename file +// - GET /api/devices/{serial}/files/exists - check file exists +func (h *DeviceHandlers) HandleDeviceFiles(w http.ResponseWriter, req *http.Request) { + // Extract device serial from PathParam (provided by PatternRouter) + deviceSerial := pathParam(req, "serial") + + if strings.Contains(req.Header.Get("via"), "gbox-device-ap") { + deviceSerial = h.serverService.GetSerialByDeviceId(deviceSerial) + } + + if deviceSerial == "" { + http.Error(w, "Device serial required", http.StatusBadRequest) + return + } + + // Determine device platform + devicePlatform := h.getDevicePlatform(deviceSerial) + + // Get workingDir from query params, default to "/" for desktop, "/data/local/tmp" for Android + workingDir := req.URL.Query().Get("workingDir") + if workingDir == "" { + if devicePlatform == "mobile" { + workingDir = "/data/local/tmp" + } else { + workingDir = "/" + } + } + + // Get action from PathParam (if present) + action := pathParam(req, "action") + + switch action { + case "list": + if req.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + h.handleDeviceFilesList(w, req, deviceSerial, devicePlatform, workingDir) + case "info": + if req.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + h.handleDeviceFilesInfo(w, req, deviceSerial, devicePlatform, workingDir) + case "rename": + if req.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + h.handleDeviceFilesRename(w, req, deviceSerial, devicePlatform, workingDir) + case "exists": + if req.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + h.handleDeviceFilesExists(w, req, deviceSerial, devicePlatform, workingDir) + case "": + // Base /files endpoint + switch req.Method { + case http.MethodGet: + h.handleDeviceFilesRead(w, req, deviceSerial, devicePlatform, workingDir) + case http.MethodPost: + h.handleDeviceFilesWrite(w, req, deviceSerial, devicePlatform, workingDir) + case http.MethodDelete: + h.handleDeviceFilesDelete(w, req, deviceSerial, devicePlatform, workingDir) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + default: + http.Error(w, "Invalid file operation path", http.StatusBadRequest) + } +} + +// handleDeviceFilesRead reads a file from the device +func (h *DeviceHandlers) handleDeviceFilesRead(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) { + path := req.URL.Query().Get("path") + if path == "" { + http.Error(w, "path parameter is required", http.StatusBadRequest) + return + } + + // Resolve absolute path + absPath := h.resolvePath(path, workingDir) + + if devicePlatform == "mobile" { + // For Android, use adb pull or cat + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + + // Use adb shell cat to read file + cmd := exec.Command(adbPath, "-s", deviceSerial, "shell", "cat", absPath) + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + if err := cmd.Run(); err != nil { + http.Error(w, fmt.Sprintf("Failed to read file: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write(stdoutBuf.Bytes()) + } else { + // For desktop, read file directly + content, err := os.ReadFile(absPath) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "File not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write(content) + } +} + +// handleDeviceFilesWrite writes content to a file on the device +func (h *DeviceHandlers) handleDeviceFilesWrite(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) { + path := req.URL.Query().Get("path") + if path == "" { + http.Error(w, "path parameter is required", http.StatusBadRequest) + return + } + + // Resolve absolute path + absPath := h.resolvePath(path, workingDir) + + // Read body content + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to read request body: %v", err), http.StatusBadRequest) + return + } + + if devicePlatform == "mobile" { + // For Android, use adb push or echo + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + + // Create a temporary file locally + tmpFile, err := os.CreateTemp("", "gbox-file-*") + if err != nil { + http.Error(w, fmt.Sprintf("Failed to create temp file: %v", err), http.StatusInternalServerError) + return + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.Write(body); err != nil { + tmpFile.Close() + http.Error(w, fmt.Sprintf("Failed to write temp file: %v", err), http.StatusInternalServerError) + return + } + tmpFile.Close() + + // Push file to device + cmd := exec.Command(adbPath, "-s", deviceSerial, "push", tmpPath, absPath) + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Run(); err != nil { + http.Error(w, fmt.Sprintf("Failed to write file: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } else { + // For desktop, write file directly + // Create parent directories if needed + parentDir := h.getParentDir(absPath) + if err := os.MkdirAll(parentDir, 0755); err != nil { + http.Error(w, fmt.Sprintf("Failed to create parent directories: %v", err), http.StatusInternalServerError) + return + } + + if err := os.WriteFile(absPath, body, 0644); err != nil { + http.Error(w, fmt.Sprintf("Failed to write file: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +// handleDeviceFilesDelete deletes a file or directory from the device +func (h *DeviceHandlers) handleDeviceFilesDelete(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) { + path := req.URL.Query().Get("path") + if path == "" { + http.Error(w, "path parameter is required", http.StatusBadRequest) + return + } + + // Resolve absolute path + absPath := h.resolvePath(path, workingDir) + + if devicePlatform == "mobile" { + // For Android, use adb shell rm + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + + cmd := exec.Command(adbPath, "-s", deviceSerial, "shell", "rm", "-rf", absPath) + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Run(); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete file: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } else { + // For desktop, delete file directly + if err := os.RemoveAll(absPath); err != nil { + if os.IsNotExist(err) { + http.Error(w, "File not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to delete file: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +// handleDeviceFilesList lists files in a directory on the device +func (h *DeviceHandlers) handleDeviceFilesList(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) { + path := req.URL.Query().Get("path") + if path == "" { + path = workingDir + } + + // Resolve absolute path + absPath := h.resolvePath(path, workingDir) + + depthStr := req.URL.Query().Get("depth") + depth := 1 + if depthStr != "" { + if d, err := strconv.Atoi(depthStr); err == nil && d > 0 { + depth = d + } + } + + if devicePlatform == "mobile" { + // For Android, use adb shell ls + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + + // Use find command for recursive listing with depth + var cmd *exec.Cmd + if depth > 1 { + maxDepth := depth + cmd = exec.Command(adbPath, "-s", deviceSerial, "shell", "find", absPath, "-maxdepth", strconv.Itoa(maxDepth), "-exec", "ls", "-ld", "{}", ";") + } else { + cmd = exec.Command(adbPath, "-s", deviceSerial, "shell", "ls", "-la", absPath) + } + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + if err := cmd.Run(); err != nil { + http.Error(w, fmt.Sprintf("Failed to list files: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError) + return + } + + // Parse ls output and convert to JSON format + // This is a simplified implementation - in production, you'd want more robust parsing + lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") + files := make([]map[string]interface{}, 0) + for _, line := range lines { + if line == "" { + continue + } + // Parse ls -la output (simplified) + parts := strings.Fields(line) + if len(parts) < 9 { + continue + } + fileInfo := map[string]interface{}{ + "name": parts[8], + "path": absPath + "/" + parts[8], + "type": "dir", + "mode": parts[0], + "lastModified": time.Now().Format(time.RFC3339), // ls doesn't provide exact time, use current + } + if strings.HasPrefix(parts[0], "-") { + fileInfo["type"] = "file" + if size, err := strconv.ParseInt(parts[4], 10, 64); err == nil { + fileInfo["size"] = size + } + } + files = append(files, fileInfo) + } + + RespondJSON(w, http.StatusOK, files) + } else { + // For desktop, use os.ReadDir + entries, err := os.ReadDir(absPath) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "Directory not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to list directory: %v", err), http.StatusInternalServerError) + return + } + + files := make([]map[string]interface{}, 0, len(entries)) + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + continue + } + + filePath := absPath + "/" + entry.Name() + if absPath == "/" { + filePath = "/" + entry.Name() + } + + fileInfo := map[string]interface{}{ + "name": entry.Name(), + "path": filePath, + "type": "file", + "mode": info.Mode().String(), + "lastModified": info.ModTime().Format(time.RFC3339), + } + + if entry.IsDir() { + fileInfo["type"] = "dir" + } else { + fileInfo["size"] = info.Size() + } + + files = append(files, fileInfo) + } + + RespondJSON(w, http.StatusOK, files) + } +} + +// handleDeviceFilesInfo gets file information +func (h *DeviceHandlers) handleDeviceFilesInfo(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) { + path := req.URL.Query().Get("path") + if path == "" { + http.Error(w, "path parameter is required", http.StatusBadRequest) + return + } + + // Resolve absolute path + absPath := h.resolvePath(path, workingDir) + + if devicePlatform == "mobile" { + // For Android, use adb shell stat + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + + format := "%n\\|%s\\|%f\\|%Y" + cmd := exec.Command(adbPath, "-s", deviceSerial, "shell", "stat", "-c", format, absPath) + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + if err := cmd.Run(); err != nil { + http.Error(w, fmt.Sprintf("Failed to get file info: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError) + return + } + + // Parse stat output + output := strings.TrimSpace(stdoutBuf.String()) + parts := strings.Split(output, "|") + if len(parts) < 4 { + http.Error(w, "Failed to parse file info", http.StatusInternalServerError) + return + } + + size, _ := strconv.ParseInt(parts[1], 10, 64) + mode, _ := strconv.ParseUint(parts[2], 16, 32) + mtime, _ := strconv.ParseInt(parts[3], 10, 64) + + fileType := "file" + if os.FileMode(mode).IsDir() { + fileType = "dir" + } + + fileInfo := map[string]interface{}{ + "name": parts[0], + "path": absPath, + "type": fileType, + "size": size, + "mode": fmt.Sprintf("%o", mode), + "lastModified": time.Unix(mtime, 0).Format(time.RFC3339), + } + + RespondJSON(w, http.StatusOK, fileInfo) + } else { + // For desktop, use os.Stat + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "File not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to get file info: %v", err), http.StatusInternalServerError) + return + } + + fileType := "file" + if info.IsDir() { + fileType = "dir" + } + + fileInfo := map[string]interface{}{ + "name": info.Name(), + "path": absPath, + "type": fileType, + "mode": info.Mode().String(), + "lastModified": info.ModTime().Format(time.RFC3339), + } + + if !info.IsDir() { + fileInfo["size"] = info.Size() + } + + RespondJSON(w, http.StatusOK, fileInfo) + } +} + +// handleDeviceFilesRename renames a file or directory +func (h *DeviceHandlers) handleDeviceFilesRename(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) { + oldPath := req.URL.Query().Get("oldPath") + newPath := req.URL.Query().Get("newPath") + if oldPath == "" || newPath == "" { + http.Error(w, "oldPath and newPath parameters are required", http.StatusBadRequest) + return + } + + // Resolve absolute paths + absOldPath := h.resolvePath(oldPath, workingDir) + absNewPath := h.resolvePath(newPath, workingDir) + + if devicePlatform == "mobile" { + // For Android, use adb shell mv + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + + cmd := exec.Command(adbPath, "-s", deviceSerial, "shell", "mv", absOldPath, absNewPath) + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Run(); err != nil { + http.Error(w, fmt.Sprintf("Failed to rename file: %v, stderr: %s", err, stderrBuf.String()), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + } else { + // For desktop, use os.Rename + if err := os.Rename(absOldPath, absNewPath); err != nil { + if os.IsNotExist(err) { + http.Error(w, "File not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to rename file: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + } +} + +// handleDeviceFilesExists checks if a file or directory exists +func (h *DeviceHandlers) handleDeviceFilesExists(w http.ResponseWriter, req *http.Request, deviceSerial, devicePlatform, workingDir string) { + path := req.URL.Query().Get("path") + if path == "" { + http.Error(w, "path parameter is required", http.StatusBadRequest) + return + } + + // Resolve absolute path + absPath := h.resolvePath(path, workingDir) + + if devicePlatform == "mobile" { + // For Android, use adb shell test + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + + // Test if file exists + cmd := exec.Command(adbPath, "-s", deviceSerial, "shell", "test", "-e", absPath) + exists := cmd.Run() == nil + + // Determine type if exists + var fileType string + if exists { + // Check if it's a directory + cmdDir := exec.Command(adbPath, "-s", deviceSerial, "shell", "test", "-d", absPath) + if cmdDir.Run() == nil { + fileType = "dir" + } else { + fileType = "file" + } + } + + if exists { + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "exists": true, + "type": fileType, + }) + } else { + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "exists": false, + }) + } + } else { + // For desktop, use os.Stat + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "exists": false, + }) + return + } + http.Error(w, fmt.Sprintf("Failed to check file existence: %v", err), http.StatusInternalServerError) + return + } + + fileType := "file" + if info.IsDir() { + fileType = "dir" + } + + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "exists": true, + "type": fileType, + }) + } +} + +// Helper methods for file operations + +// resolvePath resolves a path relative to workingDir +func (h *DeviceHandlers) resolvePath(path, workingDir string) string { + if strings.HasPrefix(path, "/") { + return path + } + if workingDir == "/" { + return "/" + path + } + return workingDir + "/" + path +} + +// getParentDir gets the parent directory of a path +func (h *DeviceHandlers) getParentDir(path string) string { + // Remove trailing slash if present + path = strings.TrimSuffix(path, "/") + lastSlash := strings.LastIndex(path, "/") + if lastSlash == -1 { + return "/" + } + if lastSlash == 0 { + return "/" + } + return path[:lastSlash] +} + +// Private helper methods + +func rewriteAppiumPayload(body []byte, deviceSerial string) ([]byte, bool) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, false + } + + updated := false + + if caps, ok := payload["capabilities"].(map[string]any); ok { + var processedFirstMatch []any + + if alwaysMatch, ok := caps["alwaysMatch"].(map[string]any); ok { + if applyAppiumSerial(alwaysMatch, deviceSerial) { + updated = true + } + } + if fm, ok := caps["firstMatch"].([]any); ok { + processedFirstMatch = make([]any, len(fm)) + copy(processedFirstMatch, fm) + for i, entry := range processedFirstMatch { + if m, ok := entry.(map[string]any); ok { + removed := removeAppiumSerialKeys(m) + processedFirstMatch[i] = m + updated = updated || removed + } + } + } + if updated { + if processedFirstMatch != nil { + caps["firstMatch"] = processedFirstMatch + } + payload["capabilities"] = caps + } + } + + if desiredCaps, ok := payload["desiredCapabilities"].(map[string]any); ok { + if applyAppiumSerial(desiredCaps, deviceSerial) { + payload["desiredCapabilities"] = desiredCaps + updated = true + } + } + + if !updated { + return nil, false + } + + newBody, err := json.Marshal(payload) + if err != nil { + return nil, false + } + return newBody, true +} + +func applyAppiumSerial(m map[string]any, serial string) bool { + if m == nil { + return false + } + + updated := false + + for _, key := range []string{"appium:udid", "udid"} { + if _, exists := m[key]; exists || key == "appium:udid" { + m[key] = serial + updated = true + } + } + + for _, key := range []string{"appium:deviceName", "deviceName"} { + if _, exists := m[key]; exists { + m[key] = serial + updated = true + } + } + + return updated +} + +func removeAppiumSerialKeys(m map[string]any) bool { + if m == nil { + return false + } + + removed := false + + for _, key := range []string{"appium:udid", "udid"} { + if _, exists := m[key]; exists { + delete(m, key) + removed = true + } + } + + return removed +} + +func (h *DeviceHandlers) handleDeviceConnect(w http.ResponseWriter, r *http.Request, deviceID string) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // TODO: Implement actual device connection via bridge manager + if h.serverService != nil { + err := h.serverService.CreateBridge(deviceID) + if err != nil { + RespondJSON(w, http.StatusInternalServerError, map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("Failed to connect to device: %v", err), + }) + return + } + } + + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "deviceId": deviceID, + "status": "connected", + }) +} + +func (h *DeviceHandlers) handleDeviceDisconnect(w http.ResponseWriter, r *http.Request, deviceID string) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // TODO: Implement actual device disconnection + if h.serverService != nil { + h.serverService.RemoveBridge(deviceID) + } + + RespondJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "deviceId": deviceID, + "status": "disconnected", + }) +} + +func (h *DeviceHandlers) handleWebSocketConnect(conn *websocket.Conn, msg map[string]interface{}) { + // TODO: Implement WebSocket connect handling + response := map[string]interface{}{ + "type": "connect-response", + "success": true, + } + conn.WriteJSON(response) +} + +func (h *DeviceHandlers) handleWebSocketOffer(conn *websocket.Conn, msg map[string]interface{}) { + // TODO: Implement WebRTC offer handling + log.Printf("Received WebRTC offer: %v", msg) +} + +func (h *DeviceHandlers) handleWebSocketICECandidate(conn *websocket.Conn, msg map[string]interface{}) { + // TODO: Implement ICE candidate handling + log.Printf("Received ICE candidate: %v", msg) +} + +func (h *DeviceHandlers) handleWebSocketDisconnect(conn *websocket.Conn, msg map[string]interface{}) { + // TODO: Implement WebSocket disconnect handling + log.Printf("WebSocket disconnect: %v", msg) +} + +// runLinuxInitNoVncScript initializes local noVNC environment on Linux hosts. +// Best-effort: returns error but callers may choose to continue. +func runLinuxInitNoVncScript() error { + if runtime.GOOS != "linux" { + return nil + } + if len(serverScripts.InitLinuxNoVncScript) == 0 { + return fmt.Errorf("init-linux-novnc script not embedded") + } + tmpFile, err := os.CreateTemp("", "gbox-init-novnc-*.sh") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + if _, err := tmpFile.Write(serverScripts.InitLinuxNoVncScript); err != nil { + tmpFile.Close() + _ = os.Remove(tmpPath) + return err + } + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + _ = os.Chmod(tmpPath, 0700) + + cmd := exec.Command("bash", tmpPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + runErr := cmd.Run() + _ = os.Remove(tmpPath) + return runErr +} + +// runLinuxInitXdotoolScript installs and sets up xdotool environment (best-effort) +func runLinuxInitXdotoolScript() error { + if runtime.GOOS != "linux" { + return nil + } + if len(serverScripts.InitLinuxXdotoolScript) == 0 { + return fmt.Errorf("init-linux-xdotool script not embedded") + } + tmpFile, err := os.CreateTemp("", "gbox-init-xdotool-*.sh") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + if _, err := tmpFile.Write(serverScripts.InitLinuxXdotoolScript); err != nil { + tmpFile.Close() + _ = os.Remove(tmpPath) + return err + } + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + _ = os.Chmod(tmpPath, 0700) + + cmd := exec.Command("bash", tmpPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + runErr := cmd.Run() + _ = os.Remove(tmpPath) + return runErr +} + +// HandleWebMStream handles WebM streaming +func (h *DeviceHandlers) HandleWebMStream(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := util.GetLogger() + logger.Info("Starting WebM mixed stream", "device", deviceSerial) + + // Set headers for WebM streaming + w.Header().Set("Content-Type", "video/webm; codecs=avc1.42E01E,opus") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Transfer-Encoding", "chunked") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Create WebM stream writer + writer := stream.NewWebMMuxer(w) + defer writer.Close() + + // Start streaming with the writer + if err := h.startStream(deviceSerial, writer, "webm"); err != nil { + logger.Error("Failed to start WebM stream", "device", deviceSerial, "error", err) + http.Error(w, fmt.Sprintf("Failed to start stream: %v", err), http.StatusInternalServerError) + return + } +} + +// HandleMP4Stream handles MP4 container streaming +func (h *DeviceHandlers) HandleMP4Stream(w http.ResponseWriter, r *http.Request, deviceSerial string) { + logger := util.GetLogger() + logger.Info("Starting fMP4 stream", "device", deviceSerial) + + // Set headers for fMP4 streaming + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Transfer-Encoding", "chunked") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Create MP4 stream writer + writer := stream.NewFMP4Muxer(w, logger) + defer writer.Close() + + // Start streaming with the writer + if err := h.startStream(deviceSerial, writer, "mp4"); err != nil { + logger.Error("Failed to start MP4 stream", "device", deviceSerial, "error", err) + http.Error(w, fmt.Sprintf("Failed to start stream: %v", err), http.StatusInternalServerError) + return + } +} + +// startStream starts a mixed audio/video stream with the given writer using StreamManager +func (h *DeviceHandlers) startStream(deviceSerial string, writer stream.Muxer, mode string) error { + logger := util.GetLogger() + + // Create stream manager for protocol abstraction + streamManager := stream.NewStreamManager(logger) + + // Configure stream + config := stream.StreamConfig{ + DeviceSerial: deviceSerial, + Mode: mode, + VideoWidth: 1920, // Will be updated from source + VideoHeight: 1080, // Will be updated from source + } + + // Start stream using stream manager + result, err := streamManager.StartStream(context.Background(), config) + if err != nil { + return fmt.Errorf("failed to start stream: %w", err) + } + defer result.Cleanup() + + // Get actual device dimensions + _, videoWidth, videoHeight := result.Source.GetConnectionInfo() + logger.Info("Device video dimensions", "width", videoWidth, "height", videoHeight) + + // Initialize the stream writer with device dimensions + if err := writer.Initialize(videoWidth, videoHeight, result.CodecParams); err != nil { + return fmt.Errorf("failed to initialize stream writer: %w", err) + } + + // Convert channels to muxer format + videoSampleCh, audioSampleCh := streamManager.ConvertToMuxerSamples(result.VideoCh, result.AudioCh) + + // Start streaming + logger.Info("Mixed stream started", "device", deviceSerial, "mode", mode) + + // Use the writer's streaming method + return writer.Stream(videoSampleCh, audioSampleCh) +} diff --git a/packages/cli/internal/server/handlers/interfaces.go b/packages/cli/internal/server/handlers/interfaces.go new file mode 100644 index 00000000..15c7f5d3 --- /dev/null +++ b/packages/cli/internal/server/handlers/interfaces.go @@ -0,0 +1,53 @@ +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 + + // Server lifecycle + Stop() error + + // ADB Expose methods + StartPortForward(boxID string, localPorts, remotePorts []int) error + StopPortForward(boxID string) error + ListPortForwards() interface{} + + ConnectAP(serial string) error + DisconnectAP(serial string) error + GetSerialByDeviceId(deviceId string) string // Gets device serialno by device ID (supports both Android and desktop) + GetDeviceInfo(serial string) interface{} // Returns DeviceDTO or nil + UpdateDeviceInfo(device interface{}) // Accepts DeviceDTO + IsDeviceConnected(serial string) bool // Checks if device is currently connected to AP + GetDeviceReconnectState(serial string) interface{} // Returns reconnect state (isReconnecting, attempt, maxRetry) + ReconnectRegisteredDevices() error // Reconnects all registered devices on server start +} + +// 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/utils.go b/packages/cli/internal/server/handlers/utils.go new file mode 100644 index 00000000..25f00c6b --- /dev/null +++ b/packages/cli/internal/server/handlers/utils.go @@ -0,0 +1,34 @@ +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) +} + +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 +} \ 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..09fef348 --- /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_expose.go b/packages/cli/internal/server/router/adb_expose.go new file mode 100644 index 00000000..0d7b491a --- /dev/null +++ b/packages/cli/internal/server/router/adb_expose.go @@ -0,0 +1,33 @@ +package router + +import ( + "net/http" + + "github.com/babelcloud/gbox/packages/cli/internal/server/handlers" +) + +// ADBExposeRouter handles all ADB expose routes +type ADBExposeRouter struct { + handlers *handlers.ADBExposeHandlers +} + +// RegisterRoutes registers all ADB expose routes +func (r *ADBExposeRouter) RegisterRoutes(mux *http.ServeMux, server interface{}) { + // Create handlers instance + r.handlers = handlers.NewADBExposeHandlers() + + // Create pattern router for ADB expose endpoints + adbExposeRouter := NewPatternRouter() + adbExposeRouter.HandleFunc("/api/adb-expose/start", r.handlers.HandleADBExposeStart) + adbExposeRouter.HandleFunc("/api/adb-expose/stop", r.handlers.HandleADBExposeStop) + adbExposeRouter.HandleFunc("/api/adb-expose/status", r.handlers.HandleADBExposeStatus) + adbExposeRouter.HandleFunc("/api/adb-expose/list", r.handlers.HandleADBExposeList) + + // Register pattern router + mux.HandleFunc("/api/adb-expose/", adbExposeRouter.ServeHTTP) +} + +// GetPathPrefix returns the path prefix for this router +func (r *ADBExposeRouter) GetPathPrefix() string { + return "/api/adb-expose" +} diff --git a/packages/cli/internal/server/router/api.go b/packages/cli/internal/server/router/api.go new file mode 100644 index 00000000..884b5d11 --- /dev/null +++ b/packages/cli/internal/server/router/api.go @@ -0,0 +1,73 @@ +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 device handlers separately for direct routing + deviceHandlers := handlers.NewDeviceHandlers(serverService) + + // Create box handlers separately + boxHandlers := handlers.NewBoxHandlers(serverService) + + // Create a unified pattern router for all /api/* routes + apiRouter := NewPatternRouter() + + // Health and status endpoints + apiRouter.HandleFunc("/api/health", r.handlers.HandleHealth) + apiRouter.HandleFunc("/api/status", r.handlers.HandleStatus) + + // Device management endpoints + apiRouter.HandleFunc("/api/devices", deviceHandlers.HandleDeviceList) + apiRouter.HandleFunc("/api/devices/register", deviceHandlers.HandleDeviceRegister) + apiRouter.HandleFunc("/api/devices/unregister", deviceHandlers.HandleDeviceUnregister) + + // Device-specific endpoints with path parameters + apiRouter.HandleFunc("/api/devices/{serial}", deviceHandlers.HandleDeviceAction) + apiRouter.HandleFunc("/api/devices/{serial}/video", deviceHandlers.HandleDeviceVideo) + apiRouter.HandleFunc("/api/devices/{serial}/audio", deviceHandlers.HandleDeviceAudio) + apiRouter.HandleFunc("/api/devices/{serial}/stream", deviceHandlers.HandleDeviceStream) + apiRouter.HandleFunc("/api/devices/{serial}/control", deviceHandlers.HandleDeviceControl) + apiRouter.HandleFunc("/api/devices/{serial}/adb", deviceHandlers.HandleDeviceAdb) + apiRouter.HandleFunc("/api/devices/{serial}/exec", deviceHandlers.HandleDeviceExec) + apiRouter.HandleFunc("/api/devices/{serial}/appium", deviceHandlers.HandleDeviceAppium) + apiRouter.HandleFunc("/api/devices/{serial}/appium/{path:.*}", deviceHandlers.HandleDeviceAppium) + + // File operations endpoints + apiRouter.HandleFunc("/api/devices/{serial}/files", deviceHandlers.HandleDeviceFiles) + apiRouter.HandleFunc("/api/devices/{serial}/files/{action}", deviceHandlers.HandleDeviceFiles) + + // Box management endpoints (proxy to remote GBOX API) + apiRouter.HandleFunc("/api/boxes", boxHandlers.HandleBoxList) + + // Server management endpoints + apiRouter.HandleFunc("/api/server/shutdown", r.handlers.HandleServerShutdown) + apiRouter.HandleFunc("/api/server/info", r.handlers.HandleServerInfo) + + // Register the unified API router + // Note: ADB Expose endpoints are handled separately by ADBExposeRouter + mux.HandleFunc("/api/", apiRouter.ServeHTTP) +} + +// 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..cf010394 --- /dev/null +++ b/packages/cli/internal/server/router/pages.go @@ -0,0 +1,43 @@ +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) + + // Create pattern router for page routes + pagesRouter := NewPatternRouter() + + // Page routes with optional trailing slash + pagesRouter.HandleFunc("/live-view", r.handlers.HandleLiveView) + pagesRouter.HandleFunc("/live-view/{path:.*}", r.handlers.HandleLiveView) + pagesRouter.HandleFunc("/adb-expose", r.handlers.HandleADBExpose) + pagesRouter.HandleFunc("/adb-expose/{path:.*}", r.handlers.HandleADBExpose) + + // Root handler (catches all unmatched routes) + pagesRouter.HandleFunc("/{path:.*}", r.handlers.HandleRoot) + + // Register pattern router + mux.HandleFunc("/", pagesRouter.ServeHTTP) +} + +// GetPathPrefix returns the path prefix for this router +func (r *PagesRouter) GetPathPrefix() string { + return "/" +} 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/pattern_router.go b/packages/cli/internal/server/router/pattern_router.go new file mode 100644 index 00000000..6c39035d --- /dev/null +++ b/packages/cli/internal/server/router/pattern_router.go @@ -0,0 +1,128 @@ +package router + +import ( + "context" + "net/http" + "regexp" +) + +// PatternRouter provides pattern-based routing with placeholder support +// Supports patterns like "/api/devices/{serial}/files" +type PatternRouter struct { + routes []routeEntry +} + +type routeEntry struct { + pattern *regexp.Regexp + handler http.HandlerFunc + keys []string +} + +// NewPatternRouter creates a new pattern router +func NewPatternRouter() *PatternRouter { + return &PatternRouter{ + routes: make([]routeEntry, 0), + } +} + +// HandleFunc registers a handler for a URL pattern with placeholders +// Pattern examples: +// - "/api/devices/{serial}" - matches /api/devices/abc123 +// - "/api/devices/{serial}/files" - matches /api/devices/abc123/files +// - "/api/devices/{serial}/files/{path:.*}" - matches /api/devices/abc123/files/any/path +func (pr *PatternRouter) HandleFunc(pattern string, handler http.HandlerFunc) { + regexPattern, keys := compilePattern(pattern) + pr.routes = append(pr.routes, routeEntry{ + pattern: regexPattern, + handler: handler, + keys: keys, + }) +} + +// ServeHTTP implements http.Handler interface +func (pr *PatternRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + for _, route := range pr.routes { + if matches := route.pattern.FindStringSubmatch(r.URL.Path); matches != nil { + // Extract path parameters and store them in request context + if len(route.keys) > 0 { + // Create a new request with path parameters + ctx := r.Context() + for i, key := range route.keys { + if i+1 < len(matches) { + ctx = withPathParam(ctx, key, matches[i+1]) + } + } + r = r.WithContext(ctx) + } + route.handler(w, r) + return + } + } + http.NotFound(w, r) +} + +// compilePattern converts a pattern with placeholders to a regular expression +// Returns the compiled regex and a list of placeholder keys +func compilePattern(pattern string) (*regexp.Regexp, []string) { + keys := make([]string, 0) + + placeholderRegex := regexp.MustCompile(`\{([^}:]+)(?::([^}]+))?\}`) + regexPattern := "^" + lastIndex := 0 + + for _, match := range placeholderRegex.FindAllStringSubmatchIndex(pattern, -1) { + start, end := match[0], match[1] + keyStart, keyEnd := match[2], match[3] + regexPattern += regexp.QuoteMeta(pattern[lastIndex:start]) + + key := pattern[keyStart:keyEnd] + keys = append(keys, key) + + // If there's a custom regex (second capturing group indices), use it; otherwise default + if len(match) >= 6 && match[4] != -1 && match[5] != -1 { + customRegex := pattern[match[4]:match[5]] + regexPattern += "(" + customRegex + ")" + } else { + regexPattern += `([^/]+)` + } + lastIndex = end + } + + regexPattern += regexp.QuoteMeta(pattern[lastIndex:]) + regexPattern += "$" + + return regexp.MustCompile(regexPattern), keys +} + +// PathParam retrieves a path parameter from the request context +// +// Use this function in your handlers to access URL path parameters defined +// with placeholders like {serial}, {id}, etc. +// +// Example: +// +// router.HandleFunc("/api/devices/{serial}/files/{filename}", handler) +// +// func handler(w http.ResponseWriter, r *http.Request) { +// serial := router.PathParam(r, "serial") +// filename := router.PathParam(r, "filename") +// // Use the parameters... +// } +// +// Returns an empty string if the parameter doesn't exist. +func PathParam(r *http.Request, key string) string { + // Use a string with prefix as context key to avoid type conflicts + contextKey := "gbox-pattern-router:" + key + if val := r.Context().Value(contextKey); val != nil { + if str, ok := val.(string); ok { + return str + } + } + return "" +} + +func withPathParam(ctx context.Context, key, value string) context.Context { + // Use a string with prefix as context key to avoid type conflicts + contextKey := "gbox-pattern-router:" + key + return context.WithValue(ctx, contextKey, value) +} 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/scripts/assets/init-linux-novnc.sh b/packages/cli/internal/server/scripts/assets/init-linux-novnc.sh new file mode 100644 index 00000000..f6102337 --- /dev/null +++ b/packages/cli/internal/server/scripts/assets/init-linux-novnc.sh @@ -0,0 +1,193 @@ +#!/bin/bash +# Unified noVNC launcher (host desktop :0 preferred; fallback to virtual :1+XFCE) +# Default: no password (-nopw). Set VNC_PASSWORD to enable password auth. +# Comments are in English. + +set -euo pipefail + +# ----------------------------- +# Config (override via env) +# ----------------------------- +USE_HOST_DESKTOP="${USE_HOST_DESKTOP:-1}" # 1 => prefer host desktop :0, 0 => force virtual :1 +DISPLAY_VIRTUAL="${DISPLAY_VIRTUAL:-:1}" # Virtual display when USE_HOST_DESKTOP=0 +RESOLUTION="${RESOLUTION:-2560x1440x24}" # WxHxD for Xvfb when virtual +VNC_PORT="${VNC_PORT:-5900}" +NOVNC_PORT="${NOVNC_PORT:-6080}" +NOVNC_WEB_DIR="${NOVNC_WEB_DIR:-/usr/share/novnc}" +AUTOLINK_INDEX="${AUTOLINK_INDEX:-1}" # 1 => symlink index.html -> vnc.html +VNC_PASSWORD="${VNC_PASSWORD:-}" # empty => no password + +# ----------------------------- +# Helpers +# ----------------------------- +port_listen() { + local port="$1" + if command -v ss >/dev/null 2>&1; then + ss -ltnp 2>/dev/null | grep -q ":${port} " + else + netstat -ltnp 2>/dev/null | grep -q ":${port} " + fi +} +wait_for_sock() { + local disp="$1" + local sock="/tmp/.X11-unix/X${disp#:}" + local i=0 + while [ $i -lt 120 ]; do + [ -S "$sock" ] && return 0 + sleep 0.1 + i=$((i+1)) + done + return 1 +} +ensure_pkg() { + #sudo apt-get update -y + sudo apt-get install -y "$@" +} +start_websockify() { + local port="$1" target="$2" + if port_listen "$port"; then + pkill -f "websockify .* ${port} " 2>/dev/null || true + sleep 0.5 + fi + nohup websockify --libserver --web "${NOVNC_WEB_DIR}" "${port}" "${target}" >/dev/null 2>&1 & + local i=0 + while [ $i -lt 80 ]; do + port_listen "${port}" && return 0 + sleep 0.1 + i=$((i+1)) + done + echo "noVNC failed to bind ${port}" >&2 + return 1 +} +x11vnc_args_for_display() { + local disp="$1" + local args=(-display "$disp" -rfbport "$VNC_PORT" -forever -shared -xrandr -ncache 0 -noxdamage) + if [ -n "$VNC_PASSWORD" ]; then + mkdir -p ~/.vnc + x11vnc -storepasswd "$VNC_PASSWORD" ~/.vnc/passwd >/dev/null 2>&1 + args+=(-rfbauth ~/.vnc/passwd) + else + args+=(-nopw) + fi + printf '%s\n' "${args[@]}" +} + +# ----------------------------- +# 1) Install dependencies +# ----------------------------- +ensure_pkg x11vnc novnc python3-websockify +# Virtual desktop deps (only used when falling back) +ensure_pkg xvfb xfce4 xfce4-terminal xfce4-goodies dbus-x11 x11-apps + +[ -d "$NOVNC_WEB_DIR" ] || { echo "noVNC dir not found: $NOVNC_WEB_DIR"; exit 1; } + +# ----------------------------- +# 2) Decide mode (prefer host :0 if socket exists) +# ----------------------------- +HOST_XSOCK="/tmp/.X11-unix/X0" +if [ "$USE_HOST_DESKTOP" = "1" ] && [ -S "$HOST_XSOCK" ]; then + MODE="host"; TARGET_DISPLAY=":0" +else + MODE="virtual"; TARGET_DISPLAY="$DISPLAY_VIRTUAL" +fi +echo "[mode] ${MODE} (DISPLAY ${TARGET_DISPLAY})" + +# ----------------------------- +# 3A) Host desktop (:0) +# ----------------------------- +if [ "$MODE" = "host" ]; then + # Clean stale processes + pkill -x x11vnc 2>/dev/null || true + pkill -f "websockify .* ${NOVNC_PORT} " 2>/dev/null || true + + # Build x11vnc args and include -auth guess for :0 + # shellcheck disable=SC2207 + VNC_ARGS=($(x11vnc_args_for_display "$TARGET_DISPLAY")) + VNC_ARGS+=(-auth guess) + + nohup x11vnc "${VNC_ARGS[@]}" >/dev/null 2>&1 & + i=0 + while [ $i -lt 80 ]; do + port_listen "$VNC_PORT" && break + sleep 0.1 + i=$((i+1)) + done + port_listen "$VNC_PORT" || { echo "x11vnc not listening on ${VNC_PORT}"; exit 1; } + + start_websockify "$NOVNC_PORT" "localhost:${VNC_PORT}" + + if [ "$AUTOLINK_INDEX" = "1" ] && [ -f "${NOVNC_WEB_DIR}/vnc.html" ]; then + ln -sf "${NOVNC_WEB_DIR}/vnc.html" "${NOVNC_WEB_DIR}/index.html" 2>/dev/null || true + fi + + echo "" + echo "-----------------------------------------" + echo "Ready (host :0)." + echo "VNC: localhost:${VNC_PORT}" + echo "noVNC: http://localhost:${NOVNC_PORT}/vnc.html?autoconnect=1&host=localhost&port=${NOVNC_PORT}" + if [ -z "$VNC_PASSWORD" ]; then + echo "Auth: none (-nopw) [WARNING: insecure on untrusted networks]" + else + echo "Auth: password (~/.vnc/passwd)" + fi + echo "-----------------------------------------" + exit 0 +fi + +# ----------------------------- +# 3B) Virtual desktop (:1 + XFCE) +# ----------------------------- +# Start Xvfb +if [ -S "/tmp/.X11-unix/X${TARGET_DISPLAY#:}" ]; then + echo "· ${TARGET_DISPLAY} exists, skip Xvfb" +else + nohup Xvfb "${TARGET_DISPLAY}" -screen 0 "${RESOLUTION}" -ac +extension RANDR -nolisten tcp -noreset >/dev/null 2>&1 & + wait_for_sock "${TARGET_DISPLAY}" || { echo "Xvfb did not come up"; exit 1; } +fi +echo "✓ X ready: ${TARGET_DISPLAY}" + +# Ensure XDG_RUNTIME_DIR and start XFCE once +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/xdg-$(id -u)}" +mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR" || true +if ! pgrep -a xfwm4 >/dev/null 2>&1 && ! pgrep -a startxfce4 >/dev/null 2>&1; then + DISPLAY="${TARGET_DISPLAY}" nohup dbus-launch startxfce4 >/tmp/xfce.log 2>&1 & + sleep 2 +fi + +# Start x11vnc on virtual display +pkill -x x11vnc 2>/dev/null || true +sleep 0.3 +# shellcheck disable=SC2207 +VNC_ARGS=($(x11vnc_args_for_display "$TARGET_DISPLAY")) +nohup x11vnc "${VNC_ARGS[@]}" >/dev/null 2>&1 & +i=0 +while [ $i -lt 80 ]; do + port_listen "$VNC_PORT" && break + sleep 0.1 + i=$((i+1)) +done +port_listen "$VNC_PORT" || { echo "x11vnc not listening on ${VNC_PORT}"; exit 1; } + +# Start websockify +start_websockify "$NOVNC_PORT" "localhost:${VNC_PORT}" + +# Optional: default to UI +if [ "$AUTOLINK_INDEX" = "1" ] && [ -f "${NOVNC_WEB_DIR}/vnc.html" ]; then + ln -sf "${NOVNC_WEB_DIR}/vnc.html" "${NOVNC_WEB_DIR}/index.html" 2>/dev/null || true +fi + +echo "" +echo "-----------------------------------------" +echo "Ready (virtual XFCE)." +echo "DISPLAY: ${TARGET_DISPLAY} (${RESOLUTION%x*})" +echo "VNC: localhost:${VNC_PORT}" +echo "noVNC: http://localhost:${NOVNC_PORT}/vnc.html?autoconnect=1&host=localhost&port=${NOVNC_PORT}" +echo "Logs: /tmp/xfce.log" +if [ -z "$VNC_PASSWORD" ]; then + echo "Auth: none (-nopw) [WARNING: insecure on untrusted networks]" +else + echo "Auth: password (~/.vnc/passwd)" +fi +echo "-----------------------------------------" + + diff --git a/packages/cli/internal/server/scripts/assets/init-linux-xdotool.sh b/packages/cli/internal/server/scripts/assets/init-linux-xdotool.sh new file mode 100644 index 00000000..9b1872b0 --- /dev/null +++ b/packages/cli/internal/server/scripts/assets/init-linux-xdotool.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Initialize xdotool environment on Linux desktop +# - Install xdotool and wmctrl +# - Detect DISPLAY (:0 preferred, fallback :1) +# - Export minimal env vars for X client tools + +set -euo pipefail + +detect_display() { + if [ -S /tmp/.X11-unix/X0 ]; then + echo ":0" + elif [ -S /tmp/.X11-unix/X1 ]; then + echo ":1" + else + # Fallback to :1 (assumed created by other init script) + echo ":1" + fi +} + +ensure_pkg() { + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y || true + sudo apt-get install -y "$@" + return 0 + fi + if command -v yum >/dev/null 2>&1; then + sudo yum install -y "$@" + return 0 + fi + if command -v dnf >/dev/null 2>&1; then + sudo dnf install -y "$@" + return 0 + fi + if command -v pacman >/dev/null 2>&1; then + sudo pacman -Sy --noconfirm "$@" + return 0 + fi + echo "Unsupported package manager. Please install: $*" >&2 + exit 1 +} + +ensure_pkg xdotool wmctrl + +export DISPLAY="${DISPLAY:-$(detect_display)}" + +# Try to set XAUTHORITY if available +USER_XAUTH="$HOME/.Xauthority" +ROOT_XAUTH="/root/.Xauthority" +if [ -f "$USER_XAUTH" ]; then + export XAUTHORITY="$USER_XAUTH" +elif [ -f "$ROOT_XAUTH" ]; then + export XAUTHORITY="$ROOT_XAUTH" +fi + +# Smoke test (best-effort) +if ! xdotool version >/dev/null 2>&1; then + echo "xdotool not available in PATH after installation" >&2 + exit 1 +fi + +echo "xdotool initialized. DISPLAY=$DISPLAY" + + diff --git a/packages/cli/internal/server/scripts/init_linux_novnc.go b/packages/cli/internal/server/scripts/init_linux_novnc.go new file mode 100644 index 00000000..7f537463 --- /dev/null +++ b/packages/cli/internal/server/scripts/init_linux_novnc.go @@ -0,0 +1,8 @@ +package scripts + +import _ "embed" + +//go:embed assets/init-linux-novnc.sh +var InitLinuxNoVncScript []byte + + diff --git a/packages/cli/internal/server/scripts/init_linux_xdotool.go b/packages/cli/internal/server/scripts/init_linux_xdotool.go new file mode 100644 index 00000000..c887474c --- /dev/null +++ b/packages/cli/internal/server/scripts/init_linux_xdotool.go @@ -0,0 +1,8 @@ +package scripts + +import _ "embed" + +//go:embed assets/init-linux-xdotool.sh +var InitLinuxXdotoolScript []byte + + diff --git a/packages/cli/internal/server/server.go b/packages/cli/internal/server/server.go new file mode 100644 index 00000000..fff7b040 --- /dev/null +++ b/packages/cli/internal/server/server.go @@ -0,0 +1,284 @@ +package server + +import ( + "context" + "embed" + "fmt" + "io/fs" + "log" + "net/http" + "sync" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/device_connect/control" + "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" + "github.com/pkg/errors" +) + +//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 + bridgeManager *webrtc.Manager + deviceKeeper *DeviceKeeper + + // State + 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()) + + // Initialize control service + control.SetControlService() + + return &GBoxServer{ + port: port, + mux: http.NewServeMux(), + bridgeManager: webrtc.NewManager("adb"), + ctx: ctx, + cancel: cancel, + } +} + +// Start starts the unified server +func (s *GBoxServer) Start() error { + // Set start time and build ID + s.startTime = time.Now() + s.buildID = GetBuildID() + + // Setup routes + s.setupRoutes() + + if err := s.startDeviceKeeper(); err != nil { + return err + } + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: loggingMiddleware(s.mux), + ReadTimeout: 0, // No read timeout for streaming connections + WriteTimeout: 0, // No write timeout for streaming connections + IdleTimeout: 0, // No idle timeout for streaming connections + } + + return s.httpServer.ListenAndServe() +} + +// Stop stops the server +func (s *GBoxServer) Stop() error { + s.cancel() + + // Shutdown HTTP server with longer timeout + if s.httpServer != nil { + 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.bridgeManager.Close() + s.deviceKeeper.Close() + + log.Println("GBox server stopped") + return nil +} + +func (s *GBoxServer) startDeviceKeeper() error { + var err error + s.deviceKeeper, err = NewDeviceKeeper() + if err != nil { + return errors.Wrap(err, "failed to create device keeper") + } + if err := s.deviceKeeper.Start(); err != nil { + return errors.Wrap(err, "failed to start device keeper") + } + 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 using the new router system +func (s *GBoxServer) setupRoutes() { + // Register routers in order of specificity (most specific first) + routers := []router.Router{ + &router.APIRouter{}, + &router.ADBExposeRouter{}, + &router.AssetsRouter{}, + &router.PagesRouter{}, // Must be last as it includes root handler + } + + // Register all routes + for _, r := range routers { + r.RegisterRoutes(s.mux, s) + } +} + +// 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 +} + +// 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", + } +} + +type loggingResponseWriter struct { + http.ResponseWriter + status int + length int +} + +func (lw *loggingResponseWriter) WriteHeader(code int) { + lw.status = code + lw.ResponseWriter.WriteHeader(code) +} + +func (lw *loggingResponseWriter) Write(b []byte) (int, error) { + if lw.status == 0 { + lw.status = http.StatusOK + } + n, err := lw.ResponseWriter.Write(b) + lw.length += n + return n, err +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lw := &loggingResponseWriter{ResponseWriter: w} + next.ServeHTTP(lw, r) + duration := time.Since(start) + remoteAddr := r.RemoteAddr + log.Printf("%s %s %d %d %s %s", r.Method, r.URL.Path, lw.status, lw.length, duration, remoteAddr) + }) +} + +func (s *GBoxServer) ConnectAP(serial string) error { + return s.deviceKeeper.connectAP(serial) +} + +func (s *GBoxServer) DisconnectAP(serial string) error { + return s.deviceKeeper.disconnectAPForce(serial) +} + +func (s *GBoxServer) GetSerialByDeviceId(deviceId string) string { + return s.deviceKeeper.getSerialByDeviceId(deviceId) +} + +func (s *GBoxServer) GetDeviceInfo(serial string) interface{} { + return s.deviceKeeper.GetDeviceInfo(serial) +} + +func (s *GBoxServer) UpdateDeviceInfo(device interface{}) { + if dto, ok := device.(*handlers.DeviceDTO); ok { + s.deviceKeeper.updateDeviceInfo(dto) + } +} + +func (s *GBoxServer) IsDeviceConnected(serial string) bool { + return s.deviceKeeper.IsDeviceConnected(serial) +} + +func (s *GBoxServer) GetDeviceReconnectState(serial string) interface{} { + return s.deviceKeeper.getReconnectState(serial) +} + +func (s *GBoxServer) ReconnectRegisteredDevices() error { + return s.deviceKeeper.ReconnectRegisteredDevices() +} 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..680b60a2 --- /dev/null +++ b/packages/cli/internal/server/static/adb-expose.html @@ -0,0 +1,419 @@ + + + + + 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 new file mode 100644 index 00000000..d6480fba --- /dev/null +++ b/packages/cli/internal/server/static/index.html @@ -0,0 +1,237 @@ + + + + + + GBOX Local Server + + + + +
+
+
+ Server Running +
+
+ Version: + Loading... +
+
+ Build ID: + Loading... +
+
+ Port: + Loading... +
+
+ Uptime: + Loading... +
+
+ + + + + + \ 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..49588dc7 --- /dev/null +++ b/packages/cli/internal/server/version.go @@ -0,0 +1,45 @@ +package server + +import ( + "fmt" + "os" + "runtime" + "time" + + "github.com/babelcloud/gbox/packages/cli/internal/version" +) + +// BuildInfo contains build-time information +var BuildInfo = struct { + Version string + BuildTime string + GitCommit string + GoVersion string +}{ + Version: version.Version, + BuildTime: time.Now().Format(time.RFC3339), + GitCommit: version.CommitID, + 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/packages/cli/internal/util/device.go b/packages/cli/internal/util/device.go new file mode 100644 index 00000000..d1f37039 --- /dev/null +++ b/packages/cli/internal/util/device.go @@ -0,0 +1,205 @@ +package util + +import ( + "os" + "os/exec" + "strings" +) + +// GetDesktopSerialNo gets the serial number for a desktop device based on OS type. +// For macOS, it tries to get the hardware serial number from system_profiler. +// For Windows, it tries to get the serial number from wmic. +// For Linux and other platforms, it uses the hostname. +// Returns the serial number or hostname as fallback. +func GetDesktopSerialNo(osType string) string { + switch strings.ToLower(osType) { + case "macos", "mac": // Support both "macos" and "mac" for backward compatibility + // For macOS, get serial number from system_profiler + cmd := exec.Command("system_profiler", "SPHardwareDataType") + output, err := cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "Serial Number") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + serialno := strings.TrimSpace(parts[1]) + if serialno != "" { + return serialno + } + } + } + } + } + // Fallback to hostname + if hostname, err := os.Hostname(); err == nil { + return hostname + } + case "windows": + // For Windows, try to get serial number from wmic + cmd := exec.Command("wmic", "bios", "get", "serialnumber") + output, err := cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.EqualFold(line, "SerialNumber") { + return line + } + } + } + // Fallback to hostname + if hostname, err := os.Hostname(); err == nil { + return hostname + } + case "linux": + // For Linux, try to get board serial number from DMI + // Try with sudo first + cmd := exec.Command("sudo", "cat", "/sys/class/dmi/id/board_serial") + output, err := cmd.Output() + if err == nil { + serialno := strings.TrimSpace(string(output)) + if serialno != "" && serialno != "Not Specified" && serialno != "Default string" { + return serialno + } + } + // Try without sudo as fallback + cmd = exec.Command("cat", "/sys/class/dmi/id/board_serial") + output, err = cmd.Output() + if err == nil { + serialno := strings.TrimSpace(string(output)) + if serialno != "" && serialno != "Not Specified" && serialno != "Default string" { + return serialno + } + } + // Fallback to hostname + if hostname, err := os.Hostname(); err == nil { + return hostname + } + default: + // Fallback to hostname for any other platform + if hostname, err := os.Hostname(); err == nil { + return hostname + } + } + return "" +} + +// DetectDesktopDeviceType detects if a desktop device is physical or virtual machine +// Returns "vm" for virtual machines, "physical" for physical devices +func DetectDesktopDeviceType(osType string) string { + switch osType { + case "linux": + // Check DMI product name + cmd := exec.Command("cat", "/sys/class/dmi/id/product_name") + output, err := cmd.Output() + if err == nil { + productName := strings.ToLower(strings.TrimSpace(string(output))) + if strings.Contains(productName, "vmware") || + strings.Contains(productName, "virtualbox") || + strings.Contains(productName, "qemu") || + strings.Contains(productName, "kvm") || + strings.Contains(productName, "parallels") || + strings.Contains(productName, "xen") { + return "vm" + } + } + + // Check DMI sys vendor + cmd = exec.Command("cat", "/sys/class/dmi/id/sys_vendor") + output, err = cmd.Output() + if err == nil { + vendor := strings.ToLower(strings.TrimSpace(string(output))) + if strings.Contains(vendor, "vmware") || + strings.Contains(vendor, "innotek") || // VirtualBox + strings.Contains(vendor, "qemu") || + strings.Contains(vendor, "xen") { + return "vm" + } + } + + case "macos": + // Check system_profiler for virtualization + cmd := exec.Command("system_profiler", "SPHardwareDataType") + output, err := cmd.Output() + if err == nil { + outputStr := strings.ToLower(string(output)) + if strings.Contains(outputStr, "vmware") || + strings.Contains(outputStr, "parallels") || + strings.Contains(outputStr, "virtualbox") { + return "vm" + } + } + + case "windows": + // Check via WMI (can be enhanced) + // For now, default to physical + } + + return "physical" +} + +// DetectAndroidDeviceType detects if an Android device is physical or emulator +// Returns "emulator" for emulators, "physical" for physical devices +func DetectAndroidDeviceType(deviceID, serialNo string) string { + // Check serial number for emulator indicators + serialUpper := strings.ToUpper(serialNo) + if strings.Contains(serialUpper, "EMULATOR") { + return "emulator" + } + + // Try to detect via ADB properties + adbPath, err := exec.LookPath("adb") + if err != nil { + adbPath = "adb" + } + + // Check hardware property + cmd := exec.Command(adbPath, "-s", deviceID, "shell", "getprop", "ro.hardware") + output, err := cmd.Output() + if err == nil { + hardware := strings.TrimSpace(string(output)) + // Common emulator hardware types + if hardware == "goldfish" || hardware == "ranchu" { + return "emulator" + } + } + + // Check product brand + cmd = exec.Command(adbPath, "-s", deviceID, "shell", "getprop", "ro.product.brand") + output, err = cmd.Output() + if err == nil { + brand := strings.ToLower(strings.TrimSpace(string(output))) + if brand == "generic" || brand == "unknown" { + return "emulator" + } + } + + return "physical" +} + +// GetMacOSChip gets the chip information from macOS +// Returns chip name (e.g., "Apple M4 Max") or "Unknown" if not available +func GetMacOSChip() string { + // Try system_profiler first (more reliable for Apple Silicon) + cmd := exec.Command("system_profiler", "SPHardwareDataType") + output, err := cmd.Output() + if err == nil { + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "Chip:") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + return strings.TrimSpace(parts[1]) + } + } + } + } + // Fallback: try sysctl for Intel Macs + cmd2 := exec.Command("sysctl", "-n", "machdep.cpu.brand_string") + output2, err2 := cmd2.Output() + if err2 == nil { + return strings.TrimSpace(string(output2)) + } + return "Unknown" +} diff --git a/packages/cli/internal/util/logger.go b/packages/cli/internal/util/logger.go new file mode 100644 index 00000000..7507f702 --- /dev/null +++ b/packages/cli/internal/util/logger.go @@ -0,0 +1,111 @@ +package util + +import ( + "fmt" + "log" + "log/slog" + "strings" +) + +// 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) { + // 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/table.go b/packages/cli/internal/util/table.go new file mode 100644 index 00000000..27d28bda --- /dev/null +++ b/packages/cli/internal/util/table.go @@ -0,0 +1,107 @@ +package util + +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) + // Calculate display width accounting for ANSI codes + displayWidth := getDisplayWidth(valueStr) + if displayWidth > columns[i].Width { + columns[i].Width = displayWidth + } + } + } + // Ensure minimum width for arrow column + if columns[i].Header == " " && columns[i].Width < 2 { + columns[i].Width = 2 + } + } + + // 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) + } + // Use custom padding function that handles ANSI codes correctly + paddedValue := padStringToWidth(value, col.Width) + rowParts = append(rowParts, paddedValue) + } + fmt.Println(strings.Join(rowParts, " ")) + } +} + +// removeANSICodes removes ANSI escape codes from a string for width calculation +func removeANSICodes(s string) string { + // Simple ANSI code removal - this could be more sophisticated + // but should handle most common cases + for { + start := strings.Index(s, "\033[") + if start == -1 { + break + } + end := strings.Index(s[start:], "m") + if end == -1 { + break + } + s = s[:start] + s[start+end+1:] + } + return s +} + +// getDisplayWidth calculates the display width of a string, accounting for ANSI codes and Unicode characters +func getDisplayWidth(s string) int { + clean := removeANSICodes(s) + // Count the number of runes (Unicode characters) instead of bytes + return len([]rune(clean)) +} + +// padStringToWidth pads a string to a specific width, accounting for ANSI codes +func padStringToWidth(s string, width int) string { + displayWidth := getDisplayWidth(s) + if displayWidth >= width { + return s + } + // Add spaces to reach the target width + result := s + strings.Repeat(" ", width-displayWidth) + return result +} diff --git a/packages/cli/internal/util/verbose.go b/packages/cli/internal/util/verbose.go new file mode 100644 index 00000000..c2a5df14 --- /dev/null +++ b/packages/cli/internal/util/verbose.go @@ -0,0 +1,174 @@ +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) { + level := slog.LevelInfo + if verbose { + 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) + } + + 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 +} + +// 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/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/eslint.config.js b/packages/live-view/eslint.config.js new file mode 100644 index 00000000..d8593673 --- /dev/null +++ b/packages/live-view/eslint.config.js @@ -0,0 +1,142 @@ +import js from '@eslint/js'; +import typescript from '@typescript-eslint/eslint-plugin'; +import typescriptParser from '@typescript-eslint/parser'; + +export default [ + js.configs.recommended, + { + files: ['**/*.{js,jsx,ts,tsx}'], + ignores: ['**/*.d.ts', '**/node_modules/**', '**/dist/**', '**/vendor/**'], + languageOptions: { + parser: typescriptParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + globals: { + // Browser globals + window: 'readonly', + document: 'readonly', + navigator: 'readonly', + console: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + requestAnimationFrame: 'readonly', + cancelAnimationFrame: 'readonly', + fetch: 'readonly', + URL: 'readonly', + performance: 'readonly', + Buffer: 'readonly', + global: 'readonly', + + // DOM types + HTMLElement: 'readonly', + HTMLDivElement: 'readonly', + HTMLVideoElement: 'readonly', + HTMLCanvasElement: 'readonly', + HTMLAudioElement: 'readonly', + CanvasRenderingContext2D: 'readonly', + DOMRect: 'readonly', + DOMMatrix: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + DOMException: 'readonly', + + // Web APIs + WebSocket: 'readonly', + RTCPeerConnection: 'readonly', + RTCDataChannel: 'readonly', + RTCIceCandidate: 'readonly', + RTCOfferOptions: 'readonly', + RTCSessionDescription: 'readonly', + MediaStream: 'readonly', + MediaStreamTrack: 'readonly', + ResizeObserver: 'readonly', + IntersectionObserver: 'readonly', + VideoDecoder: 'readonly', + EncodedVideoChunk: 'readonly', + VideoFrame: 'readonly', + VideoDecoderConfig: 'readonly', + HardwareAcceleration: 'readonly', + MediaSource: 'readonly', + SourceBuffer: 'readonly', + ReadableStreamDefaultReader: 'readonly', + BufferSource: 'readonly', + TextEncoder: 'readonly', + URLSearchParams: 'readonly', + + // Event types + Event: 'readonly', + MouseEvent: 'readonly', + TouchEvent: 'readonly', + WheelEvent: 'readonly', + KeyboardEvent: 'readonly', + CloseEvent: 'readonly', + MessageEvent: 'readonly', + + // React + React: 'readonly', + + // Jest globals + jest: 'readonly', + describe: 'readonly', + it: 'readonly', + test: 'readonly', + expect: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + + // Node.js globals for CommonJS + module: 'readonly', + require: 'readonly', + exports: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + process: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': typescript, + }, + rules: { + // TypeScript rules + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + }], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'warn', + + // Basic rules + 'no-console': 'off', // Allow console for debugging + 'no-debugger': 'error', + 'no-unused-vars': 'off', // Using TypeScript version instead + 'prefer-const': 'error', + 'no-var': 'error', + 'no-undef': 'error', + 'no-prototype-builtins': 'error', + }, + }, + { + files: ['**/*.test.{js,jsx,ts,tsx}', '**/__tests__/**/*'], + rules: { + 'no-console': 'off', // Allow console in tests + }, + }, + { + files: ['**/*.config.{js,ts}', '**/vite.config.*', '**/rollup.config.*'], + rules: { + 'no-console': 'off', // Allow console in config files + }, + }, +]; \ 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..6b01bac9 --- /dev/null +++ b/packages/live-view/index.html @@ -0,0 +1,38 @@ + + + + + + GBOX Live View + + + + +
+ + + \ No newline at end of file diff --git a/packages/live-view/jest.config.mjs b/packages/live-view/jest.config.mjs new file mode 100644 index 00000000..7e5a9fb9 --- /dev/null +++ b/packages/live-view/jest.config.mjs @@ -0,0 +1,36 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'jsdom', + roots: ['/src'], + testMatch: [ + '**/__tests__/**/*.+(ts|tsx|js)', + '**/*.(test|spec).+(ts|tsx|js)' + ], + transform: { + '^.+\\.(ts|tsx)$': ['ts-jest', { + tsconfig: 'tsconfig.test.json', + useESM: true + }] + }, + extensionsToTreatAsEsm: ['.ts', '.tsx'], + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/**/*.stories.{ts,tsx}', + '!src/**/__tests__/**', + '!src/**/test-*.{ts,tsx}' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + setupFilesAfterEnv: ['/src/setupTests.ts'], + moduleNameMapper: { + '\\.(css|less|scss|sass)$': 'identity-obj-proxy', + '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': + '/src/__mocks__/fileMock.js' + }, + testPathIgnorePatterns: [ + '/node_modules/', + '/dist/', + '/static/' + ] +}; \ No newline at end of file diff --git a/packages/live-view/package-lock.json b/packages/live-view/package-lock.json new file mode 100644 index 00000000..8b6d2e9d --- /dev/null +++ b/packages/live-view/package-lock.json @@ -0,0 +1,10961 @@ +{ + "name": "@gbox/live-view", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@gbox/live-view", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@eslint/js": "^9.36.0", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.5", + "@testing-library/jest-dom": "^6.1.5", + "@testing-library/react": "^16.3.0", + "@types/jest": "^29.5.8", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^8.44.1", + "@typescript-eslint/parser": "^8.44.1", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^9.36.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^5.2.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.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", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3", + "vite": "^5.0.8" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", + "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/core/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz", + "integrity": "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "glob": "^8.0.3", + "is-reference": "1.2.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "11.1.6", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.6.tgz", + "integrity": "sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "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 + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.34", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", + "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", + "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/type-utils": "8.46.2", + "@typescript-eslint/utils": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.46.2", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", + "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", + "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.2", + "@typescript-eslint/types": "^8.46.2", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", + "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", + "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", + "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", + "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", + "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.2", + "@typescript-eslint/tsconfig-utils": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", + "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", + "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", + "integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-with-sourcemaps": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", + "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dev": true, + "license": "ISC", + "dependencies": { + "source-map": "^0.6.1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", + "integrity": "sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", + "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^5.2.14", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.14", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", + "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-declaration-sorter": "^6.3.1", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.1", + "postcss-convert-values": "^5.1.3", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.4", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.4", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.2", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.243", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.243.tgz", + "integrity": "sha512-ZCphxFW3Q1TVhcgS9blfut1PX8lusVi2SvXQgmEEnK4TCmE1JhH2JkjJN+DNt0pJJwfBri5AROBnz2b/C+YU9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "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.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "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" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", + "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.1", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.38.0", + "@eslint/plugin-kit": "^0.4.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "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", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "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" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/generic-names": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", + "integrity": "sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^3.2.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-replace-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", + "integrity": "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==", + "dev": true, + "license": "ISC" + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz", + "integrity": "sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-from": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-3.0.0.tgz", + "integrity": "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/import-from/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "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.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-config/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-config/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", + "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/jsdom": "^20.0.0", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0", + "jsdom": "^20.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-leak-detector/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-validate/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loader-utils": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", + "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", + "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", + "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", + "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", + "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", + "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", + "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", + "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", + "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", + "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-modules": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/postcss-modules/-/postcss-modules-4.3.1.tgz", + "integrity": "sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "generic-names": "^4.0.0", + "icss-replace-symbols": "^1.1.0", + "lodash.camelcase": "^4.3.0", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "string-hash": "^1.1.1" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", + "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", + "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", + "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dev": true, + "license": "MIT", + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", + "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", + "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/promise.series": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/promise.series/-/promise.series-0.2.0.tgz", + "integrity": "sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-dts": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.2.3.tgz", + "integrity": "sha512-UgnEsfciXSPpASuOelix7m4DrmyQgiaWBnvI0TM4GxuDh5FkqW8E5hu57bCxXB90VvR1WNfLV80yEDN18UogSA==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "magic-string": "^0.30.17" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/Swatinem" + }, + "optionalDependencies": { + "@babel/code-frame": "^7.27.1" + }, + "peerDependencies": { + "rollup": "^3.29.4 || ^4", + "typescript": "^4.5 || ^5.0" + } + }, + "node_modules/rollup-plugin-peer-deps-external": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.4.tgz", + "integrity": "sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "rollup": "*" + } + }, + "node_modules/rollup-plugin-postcss": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz", + "integrity": "sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "concat-with-sourcemaps": "^1.1.0", + "cssnano": "^5.0.1", + "import-cwd": "^3.0.0", + "p-queue": "^6.6.2", + "pify": "^5.0.0", + "postcss-load-config": "^3.0.0", + "postcss-modules": "^4.0.0", + "promise.series": "^0.2.0", + "resolve": "^1.19.0", + "rollup-pluginutils": "^2.8.2", + "safe-identifier": "^0.4.2", + "style-inject": "^0.3.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "postcss": "8.x" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/rollup-pluginutils/node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "dev": true, + "license": "ISC" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "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", + "dev": true, + "license": "MIT" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", + "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-inject": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz", + "integrity": "sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylehacks": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", + "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", + "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "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 + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/packages/live-view/package.json b/packages/live-view/package.json new file mode 100644 index 00000000..f085f1b5 --- /dev/null +++ b/packages/live-view/package.json @@ -0,0 +1,76 @@ +{ + "name": "@gbox/live-view", + "version": "0.1.0", + "type": "module", + "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", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "prepublishOnly": "npm run build" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.5", + "@testing-library/jest-dom": "^6.1.5", + "@testing-library/react": "^16.3.0", + "@types/jest": "^29.5.8", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^8.44.1", + "@typescript-eslint/parser": "^8.44.1", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^9.36.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^5.2.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.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", + "ts-jest": "^29.1.1", + "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.17.0+sha512.fce8a3dd29a4ed2ec566fb53efbb04d8c44a0f05bc6f24a73046910fb9c3ce7afa35a0980500668fa3573345bd644644fa98338fa168235c80f4aa17aa17fbef" +} \ No newline at end of file diff --git a/packages/live-view/pnpm-lock.yaml b/packages/live-view/pnpm-lock.yaml new file mode 100644 index 00000000..be995013 --- /dev/null +++ b/packages/live-view/pnpm-lock.yaml @@ -0,0 +1,6666 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^9.36.0 + version: 9.36.0 + '@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)(tslib@1.14.1)(typescript@5.9.2) + '@testing-library/jest-dom': + specifier: ^6.1.5 + version: 6.8.0 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/jest': + specifier: ^29.5.8 + version: 29.5.14 + '@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: ^8.44.1 + version: 8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^8.44.1 + version: 8.44.1(eslint@9.36.0)(typescript@5.9.2) + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.7.0(vite@5.4.20(@types/node@24.5.2)) + eslint: + specifier: ^9.36.0 + version: 9.36.0 + eslint-plugin-react: + specifier: ^7.33.2 + version: 7.37.5(eslint@9.36.0) + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.36.0) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.5.2) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 + 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) + ts-jest: + specifier: ^29.1.1 + version: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.5.2))(typescript@5.9.2) + typescript: + specifier: ^5.3.3 + version: 5.9.2 + vite: + specifier: ^5.0.8 + version: 5.4.20(@types/node@24.5.2) + +packages: + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@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-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.27.1': + resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.27.1': + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.27.1': + resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@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/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.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'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@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/config-array@0.21.0': + resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.3.1': + resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.15.2': + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.36.0': + resolution: {integrity: sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.3.5': + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@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] + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.8.0': + resolution: {integrity: sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@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/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.5.2': + resolution: {integrity: sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==} + + '@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/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.33': + resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + + '@typescript-eslint/eslint-plugin@8.44.1': + resolution: {integrity: sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.44.1 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.44.1': + resolution: {integrity: sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.44.1': + resolution: {integrity: sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.44.1': + resolution: {integrity: sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.44.1': + resolution: {integrity: sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.44.1': + resolution: {integrity: sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.44.1': + resolution: {integrity: sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.44.1': + resolution: {integrity: sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.44.1': + resolution: {integrity: sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.44.1': + resolution: {integrity: sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@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 + + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + 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'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + 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.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'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + 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 + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + 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'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + 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'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.2: + resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + + 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==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + 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==} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + 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'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + 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'} + + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + + 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 + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dedent@1.7.0: + resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + 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'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + 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==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + 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@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.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@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.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-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.36.0: + resolution: {integrity: sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + 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==} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + 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==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + 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'} + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + + 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-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + 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@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + 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'} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + 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'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + 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'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + 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-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + 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-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + 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-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + 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-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + 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==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: 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-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + 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==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + 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'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + loader-utils@3.3.1: + resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} + engines: {node: '>= 12.13.0'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + 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==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + 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==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + 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'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + 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.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + 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==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.20: + resolution: {integrity: sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nwsapi@2.2.22: + resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + + 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==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + 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@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + 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'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + 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==} + + 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'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + 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'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + promise.series@0.2.0: + resolution: {integrity: sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==} + engines: {node: '>=0.12'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + 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-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + 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'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + 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'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + 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.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + 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'} + + 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'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + 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'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + 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-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + 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' + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + 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-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + 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-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + 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-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + 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 + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-jest@29.4.4: + resolution: {integrity: sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + 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'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + 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 + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@7.12.0: + resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + 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==} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + 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 + + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + 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'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@adobe/css-tools@4.4.4': {} + + '@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-syntax-async-generators@7.8.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-typescript@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-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/runtime@7.28.4': {} + + '@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 + + '@bcoe/v8-coverage@0.2.3': {} + + '@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@9.36.0)': + dependencies: + eslint: 9.36.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/config-array@0.21.0': + dependencies: + '@eslint/object-schema': 2.1.6 + debug: 4.4.1 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.3.1': {} + + '@eslint/core@0.15.2': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': + dependencies: + ajv: 6.12.6 + debug: 4.4.1 + espree: 10.4.0 + globals: 14.0.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@9.36.0': {} + + '@eslint/object-schema@2.1.6': {} + + '@eslint/plugin-kit@0.3.5': + dependencies: + '@eslint/core': 0.15.2 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 24.5.2 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.5.2 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@24.5.2) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.5.2 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 24.5.2 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.30 + '@types/node': 24.5.2 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.30 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.2 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.28.4 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.30 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 24.5.2 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + + '@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)(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: + '@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 + + '@sinclair/typebox@0.27.8': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.8.0': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.24))(@types/react@18.3.24)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.24 + '@types/react-dom': 18.3.7(@types/react@18.3.24) + + '@tootallnate/once@2.0.0': {} + + '@trysound/sax@0.2.0': {} + + '@types/aria-query@5.0.4': {} + + '@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/graceful-fs@4.1.9': + dependencies: + '@types/node': 24.5.2 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 24.5.2 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/node@24.5.2': + dependencies: + undici-types: 7.12.0 + + '@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/stack-utils@2.0.3': {} + + '@types/tough-cookie@4.0.5': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.33': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)(typescript@5.9.2)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.44.1(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/scope-manager': 8.44.1 + '@typescript-eslint/type-utils': 8.44.1(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.1(eslint@9.36.0)(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.44.1 + eslint: 9.36.0 + graphemer: 1.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.44.1(eslint@9.36.0)(typescript@5.9.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.44.1 + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.44.1 + debug: 4.4.1 + eslint: 9.36.0 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.44.1(typescript@5.9.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.9.2) + '@typescript-eslint/types': 8.44.1 + debug: 4.4.1 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.44.1': + dependencies: + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/visitor-keys': 8.44.1 + + '@typescript-eslint/tsconfig-utils@8.44.1(typescript@5.9.2)': + dependencies: + typescript: 5.9.2 + + '@typescript-eslint/type-utils@8.44.1(eslint@9.36.0)(typescript@5.9.2)': + dependencies: + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + '@typescript-eslint/utils': 8.44.1(eslint@9.36.0)(typescript@5.9.2) + debug: 4.4.1 + eslint: 9.36.0 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.44.1': {} + + '@typescript-eslint/typescript-estree@8.44.1(typescript@5.9.2)': + dependencies: + '@typescript-eslint/project-service': 8.44.1(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.9.2) + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/visitor-keys': 8.44.1 + debug: 4.4.1 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.2 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.44.1(eslint@9.36.0)(typescript@5.9.2)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0) + '@typescript-eslint/scope-manager': 8.44.1 + '@typescript-eslint/types': 8.44.1 + '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.2) + eslint: 9.36.0 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.44.1': + dependencies: + '@typescript-eslint/types': 8.44.1 + eslint-visitor-keys: 4.2.1 + + '@vitejs/plugin-react@4.7.0(vite@5.4.20(@types/node@24.5.2))': + 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(@types/node@24.5.2) + transitivePeerDependencies: + - supports-color + + abab@2.0.6: {} + + acorn-globals@7.0.1: + dependencies: + acorn: 8.15.0 + acorn-walk: 8.3.4 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + 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-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + 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.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: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + babel-jest@29.7.0(@babel/core@7.28.4): + dependencies: + '@babel/core': 7.28.4 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.28.4) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.27.1 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.4): + dependencies: + '@babel/core': 7.28.4 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.4) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.4) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.4) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.4) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.4) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.4) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.4) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.4) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.4) + + babel-preset-jest@29.6.3(@babel/core@7.28.4): + dependencies: + '@babel/core': 7.28.4 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) + + 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) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + 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: {} + + camelcase@5.3.1: {} + + camelcase@6.3.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 + + char-regex@1.0.2: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + collect-v8-coverage@1.0.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + 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: {} + + create-jest@29.7.0(@types/node@24.5.2): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@24.5.2) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + 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: {} + + css.escape@1.5.1: {} + + 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 + + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + + csstype@3.1.3: {} + + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + + 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 + + decimal.js@10.6.0: {} + + dedent@1.7.0: {} + + 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 + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-newline@3.1.0: {} + + diff-sequences@29.6.3: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + domelementtype@2.3.0: {} + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.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: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + entities@2.2.0: {} + + entities@6.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + 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@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-plugin-react-hooks@5.2.0(eslint@9.36.0): + dependencies: + eslint: 9.36.0 + + eslint-plugin-react@7.37.5(eslint@9.36.0): + 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: 9.36.0 + 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@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.36.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.21.0 + '@eslint/config-helpers': 0.3.1 + '@eslint/core': 0.15.2 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.36.0 + '@eslint/plugin-kit': 0.3.5 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.1 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + 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: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + 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 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + 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-caller-file@2.0.5: {} + + 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-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.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@14.0.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + 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 + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-escaper@2.0.2: {} + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.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: {} + + ignore@7.0.5: {} + + 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 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + 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-arrayish@0.2.1: {} + + 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-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + 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-potential-custom-element-name@1.0.1: {} + + 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-stream@2.0.1: {} + + 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: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.28.4 + '@babel/parser': 7.28.4 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.28.4 + '@babel/parser': 7.28.4 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.1 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + 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 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.5.2 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.0 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@24.5.2): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@24.5.2) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@24.5.2) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@24.5.2): + dependencies: + '@babel/core': 7.28.4 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.4) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 24.5.2 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 24.5.2 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.5.2 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 24.5.2 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.27.1 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 24.5.2 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.10 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.5.2 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.5.2 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.28.4 + '@babel/generator': 7.28.3 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4) + '@babel/types': 7.28.4 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.4) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 24.5.2 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 24.5.2 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 24.5.2 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@24.5.2): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@24.5.2) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.15.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.4 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.22 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.18.3 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.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 + + kleur@3.0.3: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@2.1.0: {} + + lines-and-columns@1.2.4: {} + + loader-utils@3.3.1: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + 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 + + lz-string@1.5.0: {} + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + math-intrinsics@1.1.0: {} + + mdn-data@2.0.14: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + min-indent@1.0.1: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + node-int64@0.4.0: {} + + node-releases@2.0.20: {} + + normalize-path@3.0.0: {} + + normalize-url@6.1.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nwsapi@2.2.22: {} + + 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 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + 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@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.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 + + p-try@2.2.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pify@5.0.0: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.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: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + promise.series@0.2.0: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + querystringify@2.2.0: {} + + 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-is@17.0.2: {} + + react-is@18.3.1: {} + + react-refresh@0.17.0: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.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 + + require-directory@2.1.1: {} + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.3: {} + + 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: {} + + 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 + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + 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 + + signal-exit@3.0.7: {} + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} + + stable@0.1.8: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string-hash@1.1.3: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + 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-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.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-color@8.1.1: + 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 + + symbol-tree@3.2.4: {} + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.1.0(typescript@5.9.2): + dependencies: + typescript: 5.9.2 + + ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@24.5.2))(typescript@5.9.2): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@24.5.2) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.2 + type-fest: 4.41.0 + typescript: 5.9.2 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.4 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.4) + jest-util: 29.7.0 + + tslib@1.14.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + 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: {} + + uglify-js@3.19.3: + optional: true + + 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 + + undici-types@7.12.0: {} + + universalify@0.2.0: {} + + 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 + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + util-deprecate@1.0.2: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.30 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + vite@5.4.20(@types/node@24.5.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.50.1 + optionalDependencies: + '@types/node': 24.5.2 + fsevents: 2.3.3 + + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@3.0.0: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + 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: {} + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + ws@8.18.3: {} + + xml-name-validator@4.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@1.10.2: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} diff --git a/packages/live-view/pnpm-workspace.yaml b/packages/live-view/pnpm-workspace.yaml new file mode 100644 index 00000000..efc037aa --- /dev/null +++ b/packages/live-view/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - esbuild diff --git a/packages/live-view/rollup.config.js b/packages/live-view/rollup.config.js new file mode 100644 index 00000000..7af897b3 --- /dev/null +++ b/packages/live-view/rollup.config.js @@ -0,0 +1,47 @@ +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: true, + declarationDir: './dist', + }), + postcss({ + modules: true, + extract: false, + inject: true, + minimize: true, + }), + ], + external: ['react', 'react-dom'], + }, + { + input: 'src/index.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/__mocks__/fileMock.js b/packages/live-view/src/__mocks__/fileMock.js new file mode 100644 index 00000000..602eb23e --- /dev/null +++ b/packages/live-view/src/__mocks__/fileMock.js @@ -0,0 +1 @@ +export default 'test-file-stub'; 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..9981c4c7 --- /dev/null +++ b/packages/live-view/src/components/AndroidLiveView.module.css @@ -0,0 +1,466 @@ +/* Android Live View - Restored Original Layout */ +.androidLiveView { + 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; +} + +/* Header */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: rgba(0, 0, 0, 0.8); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; + backdrop-filter: blur(10px); +} + +.title { + font-size: 18px; + font-weight: 600; + color: #ffffff; + margin: 0; +} + +.connectionStatus { + font-size: 14px; + color: #888; + margin: 0 16px; + flex: 1; + text-align: center; +} + +.controls { + display: flex; + gap: 8px; + align-items: center; +} + +.controls button { + padding: 8px 16px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.2s ease; + backdrop-filter: blur(10px); +} + +.controls button:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); +} + +.controls button.active { + background: #667eea; + border-color: #667eea; + color: #ffffff; +} + +/* Content Wrapper - Sidebar Layout */ +.contentWrapper { + display: flex; + flex: 1; + position: relative; + overflow: hidden; + min-width: 0; +} + +/* Sidebar - Device List Area */ +.sidebar { + width: 320px; + min-width: 300px; + max-width: 320px; + background: #2d2d2d; + padding: 0; + overflow-y: auto; + overflow-x: hidden; + flex-shrink: 0; + box-sizing: border-box; + border-right: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; +} + +/* Sidebar Header */ +.sidebarHeader { + padding: 16px; + background: rgba(0, 0, 0, 0.3); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; +} + +.sidebarTitle { + font-size: 18px; + font-weight: 600; + color: #ffffff; + margin: 0; +} + +/* Sidebar Content */ +.sidebarContent { + padding: 16px; + flex: 1; + overflow-y: auto; +} + +/* Sidebar Footer */ +.sidebarFooter { + padding: 12px 16px; + background: rgba(0, 0, 0, 0.3); + border-top: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; +} + +.sidebarConnectionStatus { + font-size: 12px; + color: #ff6b6b; + font-weight: 500; + text-align: left; +} + +.sidebarConnectionStatus.connected { + color: #4CAF50; +} + +.sidebarConnectionStatus.connecting { + color: #FF9800; +} + +.sidebarConnectionStatus.error { + color: #f44336; +} + +/* 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; +} + +/* Main Content - Video Area */ +.mainContent { + flex: 1; + display: flex; + flex-direction: column; + background: #000; + overflow: hidden; + position: relative; + height: 100%; + min-height: 0; +} + +/* Video Container */ +.videoContainer { + flex: 1; + position: relative; + background: #000; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; + box-sizing: border-box; +} + +/* Video Content - Removed, simplified structure */ + +/* Video Main Area */ +.videoMainArea { + flex: 1; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + min-height: 0; + min-width: 0; + padding: 8px; + box-sizing: border-box; + background: #000; +} + +/* Video Wrapper */ +.videoWrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + overflow: hidden; + min-height: 0; + min-width: 0; +} + +/* Video Element */ +.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; + transition: width 0.2s ease, height 0.2s ease; +} + +.video.dragging { + cursor: grabbing; +} + +/* Client Container */ +.clientContainer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; +} + +/* Touch Indicator */ +.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); +} + +.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; +} + +/* Controls Area - Positioned absolutely in video area */ +.controlsArea { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + z-index: 1000; + pointer-events: auto; +} + +/* Stats Area */ +.statsArea { + position: relative; + bottom: 0; + left: 0; + right: 0; + height: 24px; + display: flex; + 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 */ +.stats { + 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; +} + +.stats .connectionStatus { + color: #ff6b6b; + font-weight: 500; +} + +/* Toggle Button */ +.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); +} + +/* 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; + } +} \ 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..6e069a73 --- /dev/null +++ b/packages/live-view/src/components/AndroidLiveView.tsx @@ -0,0 +1,692 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { AndroidLiveViewProps, Stats, Device, ConnectionState } from '../types'; +import { WebRTCClient } from '../lib/webrtc-client'; +import { H264Client } from '../lib/separated-client'; +import { MP4Client } from '../lib/muxed-client'; +import { DeviceList } from './DeviceList'; +import { ControlButtons } from './ControlButtons'; +import { + useClipboardHandler, + useControlHandler, + useKeyboardHandler, + useWheelHandler, + useMouseHandler, + useClickHandler, + useTouchHandler +} from '../hooks'; +import { useDeviceManager } from '../hooks/useDeviceManager'; +import styles from './AndroidLiveView.module.css'; + +export const AndroidLiveView: React.FC = ({ + apiUrl = 'http://localhost:29888/api', + wsUrl = 'ws://localhost:29888', + mode = 'separated', + deviceSerial, + autoConnect = false, + showDeviceList = true, + showAndroidControls = true, + onConnect, + onDisconnect, + onError, + className, +}) => { + const videoRef = useRef(null); + const canvasRef = useRef(null); + const videoWrapperRef = useRef(null); + const containerRef = useRef(null); + const touchIndicatorRef = useRef(null); + + // Use a polymorphic client ref so we can switch among WebRTC/H264/WebM + const clientRef = 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' | 'separated' | 'muxed'>(mode as 'webrtc' | 'separated' | 'muxed'); + const [userDisconnected, setUserDisconnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [touchIndicator, setTouchIndicator] = useState<{ visible: boolean; x: number; y: number; dragging: boolean }>({ + visible: false, + x: 0, + y: 0, + dragging: false + }); + + // Use device manager hook + const { devices, currentDevice, loading, setCurrentDevice, loadDevices } = useDeviceManager({ + apiUrl, + showDeviceList, + autoConnect, + deviceSerial, + isConnected, + onError, + }); + + // Use specialized control handlers + const clipboardHandler = useClipboardHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + onError, + }); + + const controlHandler = useControlHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + }); + + const keyboardHandler = useKeyboardHandler({ + client: clientRef.current, + enabled: isConnected, + keyboardCaptureEnabled, + isConnected, + onClipboardPaste: clipboardHandler.handleClipboardPaste, + onClipboardCopy: clipboardHandler.handleClipboardCopy, + }); + + const wheelHandler = useWheelHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + }); + + const mouseHandler = useMouseHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + }); + + + const clickHandler = useClickHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + }); + + const touchHandler = useTouchHandler({ + client: clientRef.current, + enabled: isConnected, + isConnected, + }); + + // Video resize handler - centralized and debounced + const resizeVideo = React.useCallback(() => { + if (!videoWrapperRef.current) return; + + 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 = 1080; + let videoHeight = 2340; + + if (currentMode === 'webrtc' && videoRef.current) { + videoWidth = videoRef.current.videoWidth || 1080; + videoHeight = videoRef.current.videoHeight || 2340; + } else if (currentMode === 'separated' && canvasRef.current) { + // Get canvas from the container + const canvas = canvasRef.current.querySelector('canvas'); + if (canvas && canvas.width > 0 && canvas.height > 0) { + videoWidth = canvas.width; + videoHeight = canvas.height; + console.log('[Video] Using H264 canvas dimensions:', { videoWidth, videoHeight }); + } + } else if (currentMode === 'muxed' && containerRef.current) { + // For MP4 mode, find the video element created by MP4Client + const videoElement = containerRef.current.querySelector('video'); + if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { + videoWidth = videoElement.videoWidth; + videoHeight = videoElement.videoHeight; + console.log('[Video] Using MP4 video dimensions:', { videoWidth, videoHeight }); + } + } + + 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; + } + + // For H264 mode (separated), allow video to fill more of the screen + if (currentMode === 'separated') { + // Check if we're in landscape mode (width > height) + const isLandscape = availableWidth > availableHeight; + + if (isLandscape) { + // In landscape, prioritize filling the width + newWidth = availableWidth; + newHeight = availableWidth / aspectRatio; + + // If height exceeds available space, scale down proportionally + if (newHeight > availableHeight) { + const scale = availableHeight / newHeight; + newWidth *= scale; + newHeight *= scale; + } + } else { + // In portrait, prioritize filling the height + newHeight = availableHeight; + newWidth = availableHeight * aspectRatio; + + // If width exceeds available space, scale down proportionally + if (newWidth > availableWidth) { + const scale = availableWidth / newWidth; + newWidth *= scale; + newHeight *= scale; + } + } + } + + // Apply the calculated dimensions to the appropriate element + let targetElement: HTMLElement | null = null; + if (currentMode === 'webrtc') { + targetElement = videoRef.current; + } else if (currentMode === 'separated' && canvasRef.current) { + // Get canvas from the container + targetElement = canvasRef.current.querySelector('canvas'); + } else if (currentMode === 'muxed') { + // For MP4 mode, find the video element created by MP4Client + // Try multiple selectors to find the video element + const selectors = [ + '#video-mp4-container video', + '.video-container video', + 'video[src^="blob:"]' + ]; + + for (const selector of selectors) { + targetElement = document.querySelector(selector) as HTMLVideoElement; + if (targetElement) { + console.log(`[Video] Found MP4 video element with selector: ${selector}`); + break; + } + } + + if (!targetElement) { + console.log('[Video] MP4 video element not found yet, will retry later'); + } + } + + if (targetElement) { + // Apply calculated dimensions (like old code) + targetElement.style.width = `${newWidth}px`; + targetElement.style.height = `${newHeight}px`; + targetElement.style.objectFit = "contain"; + targetElement.style.display = "block"; + targetElement.style.margin = "auto"; + + console.log('[Video] Canvas dimensions set (old code style):', { + canvasWidth: newWidth, + canvasHeight: newHeight, + containerWidth: availableWidth, + containerHeight: availableHeight, + margin: "auto" + }); + } else { + console.warn('[Video] No target element found for mode:', currentMode); + } + + console.log('[Video] Resized to:', { newWidth, newHeight, availableWidth, availableHeight }); + }, [currentMode]); + + // Debounced resize handler + const debouncedResizeVideo = React.useCallback(() => { + clearTimeout((window as unknown as { resizeTimeout: number }).resizeTimeout); + (window as unknown as { resizeTimeout: number }).resizeTimeout = setTimeout(() => { + resizeVideo(); + }, 100); + }, []); + + + // Connect to device + const connectToDevice = React.useCallback(async (device: Device, forceReconnect: boolean = false) => { + console.log('[AndroidLiveView] connectToDevice called:', { + device: device.serial, + forceReconnect, + hasClient: !!clientRef.current, + isConnecting + }); + + if (!clientRef.current) { + console.log('[AndroidLiveView] No client available, returning'); + return; + } + + // Prevent multiple simultaneous connections + if (isConnecting) { + console.log('[AndroidLiveView] Connection already in progress, skipping'); + return; + } + + console.log('[AndroidLiveView] Starting connection process'); + setIsConnecting(true); + try { + setConnectionStatus('Connecting...'); + + if (clientRef.current instanceof MP4Client) { + // MP4Client needs wsUrl for control WebSocket connection + await clientRef.current.connect(device.serial, apiUrl, wsUrl, forceReconnect); + } else { + // Other clients (WebRTC, H264) + await clientRef.current.connect(device.serial, apiUrl, wsUrl); + } + + // For WebRTC, set up video element when ready + if (clientRef.current instanceof WebRTCClient && videoRef.current) { + const webrtcClient = clientRef.current as WebRTCClient; + webrtcClient.setupVideoElementWhenReady(); + } + + // Resize video after connection + setTimeout(resizeVideo, 100); + } catch (error) { + console.error('[AndroidLiveView] Connection failed:', error); + onError?.(error as Error); + } finally { + setIsConnecting(false); + } + }, [apiUrl, wsUrl, onError]); + + // Disconnect from device (for mode switching) + const disconnectFromDevice = React.useCallback(async () => { + setUserDisconnected(true); // Mark as user-initiated disconnect + setIsConnecting(false); // Reset connecting state + + if (clientRef.current) { + await clientRef.current.disconnect(); + clientRef.current = null; + } + setIsConnected(false); + setConnectionStatus(''); + onDisconnect?.(); + }, [onDisconnect]); + + // Reset connection (for device switching) + const resetDeviceConnection = React.useCallback(async () => { + setIsConnecting(false); // Reset connecting state + if (clientRef.current && clientRef.current instanceof H264Client) { + await clientRef.current.resetConnection(); + } else if (clientRef.current) { + // For other client types, just disconnect but keep the client reference + // This allows for reconnection in device switching scenarios + clientRef.current.disconnect(); + } + setIsConnected(false); + setConnectionStatus(''); + }, []); + + // Handle mode change + const handleModeChange = React.useCallback((newMode: 'webrtc' | 'separated' | 'muxed') => { + if (newMode !== currentMode) { + console.log(`[AndroidLiveView] Mode changing from ${currentMode} to ${newMode}`); + + // Update URL parameter + const url = new URL(window.location.href); + url.searchParams.set('mode', newMode); + window.history.replaceState({}, '', url.toString()); + + // If we have a connected device, preserve the connection state + const wasConnected = isConnected; + const connectedDevice = currentDevice; + + // Reset connection state temporarily + setIsConnected(false); + setConnectionStatus(''); + + setCurrentMode(newMode); + + // If we had a connected device, reconnect it in the new mode + if (wasConnected && connectedDevice && !isConnecting) { + console.log(`[AndroidLiveView] Reconnecting device ${connectedDevice.serial} in ${newMode} mode`); + setTimeout(() => { + if (clientRef.current) { + connectToDevice(connectedDevice, false); // Mode change doesn't need force reconnect + } + }, 100); + } + } + }, [currentMode, isConnected, currentDevice]); + + // Handle device selection + const handleDeviceSelect = React.useCallback(async (device: Device) => { + console.log(`[AndroidLiveView] Device selection: ${device.serial} (currently connected: ${isConnected})`); + + // Check if it's the same device + const isDifferentDevice = currentDevice && currentDevice.serial !== device.serial; + + // If currently connected, reset connection (keep UI elements) + if (isConnected) { + await resetDeviceConnection(); + } + + // Set new device and reset disconnect flag + setCurrentDevice(device); + setUserDisconnected(false); // Reset user disconnect flag when selecting new device + + // Connect to the new device immediately + if (clientRef.current) { + console.log('[AndroidLiveView] Connecting to new device immediately'); + setTimeout(() => { + if (clientRef.current) { + connectToDevice(device, !!isDifferentDevice); + } + }, 50); + } + }, [isConnected, currentDevice, resetDeviceConnection, connectToDevice]); + + // Handle control actions + const handleControlAction = React.useCallback((action: string) => { + controlHandler.handleControlAction(action); + }, [controlHandler]); + + // Handle IME switch + const handleIMESwitch = React.useCallback(() => { + controlHandler.handleIMESwitch(); + }, [controlHandler]); + + // 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 })); + }, []); + + + // Effects + useEffect(() => { + console.log('[AndroidLiveView] Initializing client for mode:', currentMode); + + // Clean up existing client + if (clientRef.current) { + console.log('[AndroidLiveView] Cleaning up existing client before mode change'); + clientRef.current.disconnect(); + clientRef.current = null; + } + + // Create new client based on mode + if (currentMode === 'webrtc' && videoRef.current) { + clientRef.current = new WebRTCClient(videoRef.current, { + onConnectionStateChange: (state, message) => { + setConnectionStatus(message || state); + setIsConnected(state === 'connected'); + if (state === 'connected' && currentDevice) { + onConnect?.(currentDevice); + } else if (state === 'disconnected') { + onDisconnect?.(); + } + }, + onError: (error) => { + onError?.(error); + }, + onStatsUpdate: (stats) => { + setStats(stats); + }, + enableAudio: true, + audioCodec: 'opus', + }); + } else if (currentMode === 'separated' && canvasRef.current) { + clientRef.current = new H264Client(canvasRef.current, { + onConnectionStateChange: (state: string, message?: string) => { + console.log(`[AndroidLiveView] H264Client connection state change:`, { state, message }); + setConnectionStatus(message || state); + setIsConnected(state === 'connected'); + console.log(`[AndroidLiveView] isConnected set to:`, state === 'connected'); + if (state === 'connected' && currentDevice) { + onConnect?.(currentDevice); + } else if (state === 'disconnected') { + onDisconnect?.(); + } + }, + onError: (error: Error) => { + onError?.(error); + }, + onStatsUpdate: (stats: Stats) => { + setStats(stats); + }, + enableAudio: true, + audioCodec: 'opus', + }); + } else if (currentMode === 'muxed' && containerRef.current) { + clientRef.current = new MP4Client({ + onConnectionStateChange: (state: ConnectionState, message?: string) => { + setConnectionStatus(message || state); + setIsConnected(state === 'connected'); + if (state === 'connected' && currentDevice) { + onConnect?.(currentDevice); + } else if (state === 'disconnected') { + onDisconnect?.(); + } + }, + onError: (error: Error) => { + onError?.(error); + }, + onStatsUpdate: (stats: Stats) => { + setStats(stats); + }, + }); + } + + return () => { + if (clientRef.current) { + clientRef.current.disconnect(); + clientRef.current = null; + } + }; + }, [currentMode]); + + // Remove automatic connection useEffect to prevent multiple triggers + // Connection will be handled manually in handleDeviceSelect and handleModeChange + + // Handle device switching - recreate client if needed + useEffect(() => { + if (currentDevice && !isConnected && !userDisconnected && !clientRef.current) { + console.log('[AndroidLiveView] Device switched, need to recreate client'); + // The client creation will be handled by the mode change useEffect + // This is just to ensure we don't miss device switches + } + }, [currentDevice, isConnected, userDisconnected]); + + useEffect(() => { + window.addEventListener('resize', debouncedResizeVideo); + return () => { + window.removeEventListener('resize', debouncedResizeVideo); + }; + }, []); + + return ( +
+ {/* Content Wrapper - Sidebar Layout */} +
+ {/* Sidebar - Mode Switcher and Device List */} + {showDeviceList && ( +
+ {/* Sidebar Header */} +
+
Android Live View
+
+ + {/* Sidebar Content */} +
+ {/* Streaming Mode Section */} +
+
Streaming Mode
+
+ + + +
+
+ + {/* Device List Section */} + +
+ + {/* Sidebar Footer - Connection Status */} +
+
+ {connectionStatus || (isConnected ? 'Connected successfully' : 'Disconnected')} +
+
+
+ )} + + {/* Main Content - Video Area */} +
+
+ {/* 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} + > + {currentMode === 'webrtc' ? ( +
+
+
+ + {/* Touch Indicator */} +
+
+ ); +}; + 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..0538da5c --- /dev/null +++ b/packages/live-view/src/components/ControlButtons.module.css @@ -0,0 +1,75 @@ +.controlButtons { + display: flex; + flex-direction: column; + gap: 6px; + 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.6); + margin: 0; + box-sizing: border-box; + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.2); + min-width: 60px; + overflow: hidden; +} + + +.controlBtn { + width: 44px; + height: 44px; + border: none; + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + padding: 0; + margin: 0; + color: rgba(255, 255, 255, 0.9); + position: relative; + box-sizing: border-box; + outline: none; +} + +.controlBtn:hover { + 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 { + background: rgba(255, 255, 255, 0.3); + transform: scale(0.95); +} + +.controlBtn svg { + width: 20px; + height: 20px; + display: block; + margin: 0; + padding: 0; +} + +.separator { + width: 44px; + height: 1px; + background: rgba(255, 255, 255, 0.15); + cursor: default; + border-radius: 0; + margin: 4px 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..9100c22c --- /dev/null +++ b/packages/live-view/src/components/ControlButtons.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +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, + onDisconnect, + isVisible: _isVisible = true, + onToggleVisibility, + showDisconnect = false +}) => { + 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 }, + ...(showDisconnect ? [ + { id: 'separator3', isSeparator: true }, + { id: 'disconnect', title: 'Disconnect', icon: DisconnectIcon, isDisconnect: true } + ] : []), + ]; + + return ( +
+ {buttons.map((button) => { + if (button.isSeparator) { + return
; + } + + const Icon = button.icon; + const handleClick = () => { + if (button.isIMESwitch && onIMESwitch) { + onIMESwitch(); + } else if (button.isDisconnect && onDisconnect) { + onDisconnect(); + } else if (button.id === 'toggle_visibility' && onToggleVisibility) { + onToggleVisibility(); + } 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 */} + + +); + +// Unused icon components removed + +const DisconnectIcon = () => ( + + + {/* Disconnect/Stop symbol - square stop button */} + + +); 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..448c6646 --- /dev/null +++ b/packages/live-view/src/components/DeviceList.module.css @@ -0,0 +1,115 @@ +.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: 12px; + 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; + gap: 10px; +} + +.deviceItem:hover { + background: #4d4d4d; +} + +.deviceItem.connected { + background: #2d5a2d; + cursor: default; +} + +.deviceInfo { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.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: 4px; + cursor: pointer; + 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 { + 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..6e31a3ce --- /dev/null +++ b/packages/live-view/src/components/DeviceList.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { Device } from '../types'; +import styles from './DeviceList.module.css'; + +interface DeviceListProps { + devices: Device[]; + currentDevice: Device | null; + connectionStatus: string; + isConnected: boolean; + loading: boolean; + onConnect: (device: Device) => Promise; + onDisconnect: () => void; + onRefresh: () => void; +} + +export const DeviceList: React.FC = ({ + devices, + currentDevice, + connectionStatus, + isConnected, + loading, + onConnect, + onDisconnect, + onRefresh: _onRefresh +}) => { + const getDeviceStatus = (device: Device): string => { + if (currentDevice?.serial === device.serial && connectionStatus) { + return connectionStatus; + } + if (device.connected || (currentDevice?.serial === device.serial && isConnected)) { + return device.videoWidth && device.videoHeight + ? `Connected - ${device.videoWidth}x${device.videoHeight}` + : 'Connected'; + } + return device.state; + }; + + const getStatusClass = (device: Device): string => { + if (currentDevice?.serial === device.serial && connectionStatus) { + if (connectionStatus.includes('Connecting') || + connectionStatus.includes('reconnecting') || + connectionStatus.includes('Reconnecting')) { + return styles.connecting; + } else if (connectionStatus.includes('failed') || + connectionStatus.includes('Failed') || + connectionStatus.includes('disconnected')) { + return styles.error; + } + } + return ''; + }; + + return ( +
+

Device List

+ + {loading && ( +
+
+ Loading devices... +
+ )} + + {!loading && devices.length === 0 && ( +
No devices found
+ )} + + {devices.map((device) => { + // Simplified connection state logic + const isDeviceConnected = currentDevice?.serial === device.serial && isConnected; + + return ( +
{ + if (!isDeviceConnected && device.state === 'device') { + await onConnect(device); + } + }} + > +
+
{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..bdc9e562 --- /dev/null +++ b/packages/live-view/src/components/LiveView.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { AndroidLiveView } from './AndroidLiveView'; +import { LiveViewProps } from '../types'; + +/** + * Generic LiveView component that can be extended for different device types + * Now uses the refactored implementation by default + */ +export const LiveView: React.FC = (props) => { + // Use the refactored AndroidLiveView by default + // The refactored version provides better architecture and maintainability + return ; +}; \ No newline at end of file diff --git a/packages/live-view/src/hooks/README.md b/packages/live-view/src/hooks/README.md new file mode 100644 index 00000000..93534780 --- /dev/null +++ b/packages/live-view/src/hooks/README.md @@ -0,0 +1,145 @@ +# Hooks Documentation + +## Overview + +The hooks are now split into specialized, focused modules for better maintainability: + +## Specialized Hooks + +### `useClipboardHandler` +Handles clipboard operations (copy/paste). + +```typescript +const { handleClipboardPaste, handleClipboardCopy } = useClipboardHandler({ + client, + enabled: true, + isConnected: true, + onError: (error) => console.error(error), +}); +``` + +### `useControlHandler` +Handles control actions (power, volume, back, home, etc.). + +```typescript +const { handleControlAction, handleIMESwitch } = useControlHandler({ + client, + enabled: true, + isConnected: true, +}); +``` + +### `useKeyboardHandler` +Handles keyboard events and key mapping. + +```typescript +const { handleKeyDown, handleKeyUp } = useKeyboardHandler({ + client, + enabled: true, + keyboardCaptureEnabled: true, + isConnected: true, + onClipboardPaste: clipboardHandler.handleClipboardPaste, + onClipboardCopy: clipboardHandler.handleClipboardCopy, +}); +``` + +### `useWheelHandler` +Handles wheel/scroll events. + +```typescript +const { handleWheel } = useWheelHandler({ + client, + enabled: true, + isConnected: true, +}); +``` + +### `useMouseHandler` +Handles mouse events and drag state. + +```typescript +const { handleMouseDown, handleMouseUp, handleMouseMove, handleMouseLeave, isMouseDragging } = useMouseHandler({ + client, + enabled: true, + isConnected: true, +}); +``` + +### `useClickHandler` +Handles click events (single, double, triple click). + +```typescript +const { handleClick } = useClickHandler({ + client, + enabled: true, + isConnected: true, +}); +``` + +### `useTouchHandler` +Handles touch events. + +```typescript +const { handleTouchStart, handleTouchEnd, handleTouchMove } = useTouchHandler({ + client, + enabled: true, + isConnected: true, +}); +``` + +## Usage Example + +Here's how to use the specialized hooks in a React component: + +```typescript +import { + useClipboardHandler, + useControlHandler, + useKeyboardHandler, + useWheelHandler, + useMouseHandler, + useClickHandler, + useTouchHandler +} from '../hooks'; + +function MyComponent() { + const clipboardHandler = useClipboardHandler({ client, enabled, isConnected, onError }); + const controlHandler = useControlHandler({ client, enabled, isConnected }); + const keyboardHandler = useKeyboardHandler({ + client, + enabled, + keyboardCaptureEnabled, + isConnected, + onClipboardPaste: clipboardHandler.handleClipboardPaste, + onClipboardCopy: clipboardHandler.handleClipboardCopy, + }); + const wheelHandler = useWheelHandler({ client, enabled, isConnected }); + const mouseHandler = useMouseHandler({ client, enabled, isConnected }); + const clickHandler = useClickHandler({ client, enabled, isConnected }); + const touchHandler = useTouchHandler({ client, enabled, isConnected }); + + return ( +