diff --git a/README.md b/README.md index 89307e3..1b5ecac 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ A universal command-line tool for managing iOS and Android devices, simulators, - **Screencapture video streaming**: Stream mjpeg/h264 video directly from device - **Device Control**: Reboot devices, tap screen coordinates, press hardware buttons - **App Management**: Launch, terminate, install, uninstall, list, and get foreground apps +- **Filesystem**: Push, pull, list, mkdir, and rm files on-device or in app containers (Android, iOS Simulator) - **Crash Reports**: List and fetch crash reports from iOS and Android devices ### 🎯 Platform Support @@ -221,6 +222,88 @@ Example output for `apps foreground`: } ``` +### Filesystem 📂 + +Access files on the device or inside an app's data container. Currently supported on **Android** and **iOS Simulator**. + +```bash +# Get the data container path of an app (Android) +mobilecli apps path --device + +# List files at any absolute path (defaults to device root if omitted) +mobilecli fs ls --device +mobilecli fs ls --device /sdcard +mobilecli fs ls --device /sdcard/Download + +# List files inside an app's data container +mobilecli fs ls --device com.example.app +mobilecli fs ls --device com.example.app /Documents + +# Pull a file from the device to local disk +mobilecli fs pull --device /sdcard/recording.mp4 ./recording.mp4 + +# Pull a file from an app's private container +mobilecli fs pull --device /data/user/0/com.example.app/files/db.sqlite ./db.sqlite + +# Push a file to the device +mobilecli fs push --device ./config.json /sdcard/config.json + +# Push a file into an app's private container +mobilecli fs push --device ./config.json /data/user/0/com.example.app/files/config.json + +# Create a directory +mobilecli fs mkdir --device /sdcard/myfolder + +# Create a directory and all parent directories +mobilecli fs mkdir --device -p /sdcard/a/b/c +mobilecli fs mkdir --device -p /data/user/0/com.example.app/files/cache/v2 + +# Remove a file +mobilecli fs rm --device /sdcard/old_file.txt + +# Remove a directory recursively +mobilecli fs rm --device -r /sdcard/myfolder +mobilecli fs rm --device -r /data/user/0/com.example.app/files/cache +``` + +Example output for `apps path`: +```json +{ + "status": "ok", + "data": { + "path": "/data/user/0/com.example.app" + } +} +``` + +Example output for `fs ls`: +```json +{ + "status": "ok", + "data": [ + { + "name": "files", + "path": "/data/user/0/com.example.app/files", + "size": 4096, + "modTime": "2026-05-11T19:20:00Z", + "isDir": true + }, + { + "name": "shared_prefs", + "path": "/data/user/0/com.example.app/shared_prefs", + "size": 4096, + "modTime": "2026-05-11T12:49:00Z", + "isDir": true + } + ] +} +``` + +**Notes:** +- Paths under `/data/user/` are accessed via `run-as`, so the app must be debuggable. +- Pushing to `/data/user/` stages the file through `/data/local/tmp/` then copies it into the container. +- Pulling binary files (images, databases, DEX files) is fully supported and binary-safe on all platforms. + ### Agent Management 🤖 On **iOS**, the on-device agent is required for touch input (taps, swipes, button presses), screen capture streaming, and UI tree inspection. These capabilities are not available through standard iOS tooling without an agent running on the device. diff --git a/cli/apps.go b/cli/apps.go index 6acd95f..49cb87a 100644 --- a/cli/apps.go +++ b/cli/apps.go @@ -132,6 +132,25 @@ var appsUninstallCmd = &cobra.Command{ }, } +var appsPathCmd = &cobra.Command{ + Use: "path [bundle_id]", + Short: "Get the container path of an app on a device", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + req := commands.AppPathRequest{ + DeviceID: deviceId, + BundleID: args[0], + } + + response := commands.AppPathCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + var appsForegroundCmd = &cobra.Command{ Use: "foreground", Short: "Get the currently foreground app on a device", @@ -159,6 +178,7 @@ func init() { appsCmd.AddCommand(appsInstallCmd) appsCmd.AddCommand(appsUninstallCmd) appsCmd.AddCommand(appsForegroundCmd) + appsCmd.AddCommand(appsPathCmd) appsLaunchCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to launch app on") appsLaunchCmd.Flags().StringVar(&locale, "locale", "", "Comma-separated BCP 47 locale tags (e.g., fr-FR,en-GB)") @@ -170,4 +190,5 @@ func init() { appsInstallCmd.Flags().StringVar(&signingIdentity, "signing-identity", "", "Signing identity name to use for re-signing") appsUninstallCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to uninstall app from") appsForegroundCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to get foreground app from") + appsPathCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") } diff --git a/cli/fs.go b/cli/fs.go new file mode 100644 index 0000000..9b7588d --- /dev/null +++ b/cli/fs.go @@ -0,0 +1,156 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/mobile-next/mobilecli/commands" + "github.com/spf13/cobra" +) + +var fsCmd = &cobra.Command{ + Use: "fs", + Short: "Access device filesystem", + Long: `Push, pull, list, and manage files on a device or in an app's container.`, +} + +var fsPushCmd = &cobra.Command{ + Use: "push ", + Short: "Push a file to the device or into an app's container", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + req := commands.FsPushRequest{ + DeviceID: deviceId, + LocalPath: args[0], + RemotePath: args[1], + } + response := commands.FsPushCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var fsPullCmd = &cobra.Command{ + Use: "pull ", + Short: "Pull a file from the device or from an app's container", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + req := commands.FsPullRequest{ + DeviceID: deviceId, + RemotePath: args[0], + LocalPath: args[1], + } + response := commands.FsPullCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var fsLsCmd = &cobra.Command{ + Use: "ls [bundle-id] [remote-path]", + Short: "List files on the device or in an app's container", + Args: cobra.RangeArgs(0, 2), + RunE: func(cmd *cobra.Command, args []string) error { + var bundleID, remotePath string + switch len(args) { + case 1: + if strings.HasPrefix(args[0], "/") { + remotePath = args[0] + } else { + bundleID = args[0] + } + case 2: + bundleID = args[0] + remotePath = args[1] + } + req := commands.FsListRequest{ + DeviceID: deviceId, + BundleID: bundleID, + RemotePath: remotePath, + } + response := commands.FsListCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var ( + fsMkdirParents bool + fsRmRecursive bool +) + +var fsMkdirCmd = &cobra.Command{ + Use: "mkdir [bundle-id] ", + Short: "Create a directory on the device or in an app's container", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + var bundleID, remotePath string + if len(args) == 1 { + remotePath = args[0] + } else { + bundleID = args[0] + remotePath = args[1] + } + req := commands.FsMkdirRequest{ + DeviceID: deviceId, + BundleID: bundleID, + RemotePath: remotePath, + Parents: fsMkdirParents, + } + response := commands.FsMkdirCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var fsRmCmd = &cobra.Command{ + Use: "rm [bundle-id] ", + Short: "Remove a file or directory on the device or in an app's container", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + var bundleID, remotePath string + if len(args) == 1 { + remotePath = args[0] + } else { + bundleID = args[0] + remotePath = args[1] + } + req := commands.FsRmRequest{ + DeviceID: deviceId, + BundleID: bundleID, + RemotePath: remotePath, + Recursive: fsRmRecursive, + } + response := commands.FsRmCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(fsCmd) + + fsCmd.AddCommand(fsPushCmd) + fsCmd.AddCommand(fsPullCmd) + fsCmd.AddCommand(fsLsCmd) + fsCmd.AddCommand(fsMkdirCmd) + fsCmd.AddCommand(fsRmCmd) + + fsMkdirCmd.Flags().BoolVarP(&fsMkdirParents, "parents", "p", false, "Create parent directories as needed") + fsRmCmd.Flags().BoolVarP(&fsRmRecursive, "recursive", "r", false, "Remove directories and their contents recursively") +} diff --git a/cli/root.go b/cli/root.go index 7e93d21..4d1e55b 100644 --- a/cli/root.go +++ b/cli/root.go @@ -121,6 +121,25 @@ REMOTE DEVICES: # Release an allocated remote device mobilecli remote release --device +FILESYSTEM: + # List files on the device + mobilecli fs ls --device /sdcard + + # List files in an app's container + mobilecli fs ls --device com.example.app /Documents + + # Pull a file from the device + mobilecli fs pull --device /sdcard/file.txt ./file.txt + + # Push a file to the device + mobilecli fs push --device ./file.txt /sdcard/file.txt + + # Create a directory + mobilecli fs mkdir --device -p /sdcard/a/b/c + + # Remove a file or directory + mobilecli fs rm --device -r /sdcard/myfolder + UTILITIES: # Open a URL or deep link mobilecli url --device https://example.com diff --git a/commands/apps.go b/commands/apps.go index 2d0203c..c757f2a 100644 --- a/commands/apps.go +++ b/commands/apps.go @@ -155,6 +155,31 @@ func InstallAppCommand(req InstallAppRequest) *CommandResponse { }) } +type AppPathRequest struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` +} + +func AppPathCommand(req AppPathRequest) *CommandResponse { + if req.BundleID == "" { + return NewErrorResponse(fmt.Errorf("bundle ID is required")) + } + + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %w", err)) + } + + path, err := device.GetAppContainerPath(req.BundleID) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to get app path on device %s: %w", device.ID(), err)) + } + + return NewSuccessResponse(map[string]any{ + "path": path, + }) +} + type UninstallAppRequest struct { DeviceID string `json:"deviceId"` PackageName string `json:"packageName"` diff --git a/commands/fs.go b/commands/fs.go new file mode 100644 index 0000000..86250f5 --- /dev/null +++ b/commands/fs.go @@ -0,0 +1,142 @@ +package commands + +import ( + "errors" + "fmt" + "os" +) + +type FsPushRequest struct { + DeviceID string `json:"deviceId"` + LocalPath string `json:"localPath"` + RemotePath string `json:"remotePath"` +} + +func FsPushCommand(req FsPushRequest) *CommandResponse { + if req.LocalPath == "" { + return NewErrorResponse(fmt.Errorf("local path is required")) + } + if req.RemotePath == "" { + return NewErrorResponse(fmt.Errorf("remote path is required")) + } + + if _, err := os.Stat(req.LocalPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return NewErrorResponse(fmt.Errorf("local file not found: %s", req.LocalPath)) + } + return NewErrorResponse(fmt.Errorf("cannot stat local file %s: %w", req.LocalPath, err)) + } + + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %w", err)) + } + + if err := device.PushFile(req.LocalPath, req.RemotePath); err != nil { + return NewErrorResponse(fmt.Errorf("failed to push file: %w", err)) + } + + return NewSuccessResponse(map[string]any{ + "message": fmt.Sprintf("Pushed '%s' to '%s'", req.LocalPath, req.RemotePath), + }) +} + +type FsPullRequest struct { + DeviceID string `json:"deviceId"` + RemotePath string `json:"remotePath"` + LocalPath string `json:"localPath"` +} + +func FsPullCommand(req FsPullRequest) *CommandResponse { + if req.RemotePath == "" { + return NewErrorResponse(fmt.Errorf("remote path is required")) + } + if req.LocalPath == "" { + return NewErrorResponse(fmt.Errorf("local path is required")) + } + + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %w", err)) + } + + if err := device.PullFile(req.RemotePath, req.LocalPath); err != nil { + return NewErrorResponse(fmt.Errorf("failed to pull file: %w", err)) + } + + return NewSuccessResponse(map[string]any{ + "message": fmt.Sprintf("Pulled '%s' to '%s'", req.RemotePath, req.LocalPath), + }) +} + +type FsListRequest struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + RemotePath string `json:"remotePath"` +} + +func FsListCommand(req FsListRequest) *CommandResponse { + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %w", err)) + } + + entries, err := device.ListFiles(req.BundleID, req.RemotePath) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to list files: %w", err)) + } + + return NewSuccessResponse(entries) +} + +type FsMkdirRequest struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + RemotePath string `json:"remotePath"` + Parents bool `json:"parents"` +} + +func FsMkdirCommand(req FsMkdirRequest) *CommandResponse { + if req.RemotePath == "" { + return NewErrorResponse(fmt.Errorf("remote path is required")) + } + + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %w", err)) + } + + if err := device.Mkdir(req.BundleID, req.RemotePath, req.Parents); err != nil { + return NewErrorResponse(fmt.Errorf("failed to create directory: %w", err)) + } + + return NewSuccessResponse(map[string]any{ + "message": fmt.Sprintf("Created directory '%s'", req.RemotePath), + }) +} + +type FsRmRequest struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + RemotePath string `json:"remotePath"` + Recursive bool `json:"recursive"` +} + +func FsRmCommand(req FsRmRequest) *CommandResponse { + if req.RemotePath == "" { + return NewErrorResponse(fmt.Errorf("remote path is required")) + } + + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %w", err)) + } + + if err := device.Rm(req.BundleID, req.RemotePath, req.Recursive); err != nil { + return NewErrorResponse(fmt.Errorf("failed to remove: %w", err)) + } + + return NewSuccessResponse(map[string]any{ + "message": fmt.Sprintf("Removed '%s'", req.RemotePath), + }) +} diff --git a/devices/android.go b/devices/android.go index d0f958a..d831aa9 100644 --- a/devices/android.go +++ b/devices/android.go @@ -953,13 +953,29 @@ func (d *AndroidDevice) GetAppPath(packageName string) (string, error) { return "", nil } - // remove the "package:" prefix - appPath := strings.TrimPrefix(string(output), "package:") - // trim all whitespace including \r\n (CRLF on Windows) + // take only the first line (split APKs produce multiple package: lines) + firstLine := strings.SplitN(string(output), "\n", 2)[0] + appPath := strings.TrimPrefix(firstLine, "package:") appPath = strings.TrimSpace(appPath) return appPath, nil } +func (d *AndroidDevice) GetAppContainerPath(packageName string) (string, error) { + output, err := d.runAdbCommand("shell", "pm", "dump", packageName) + if err != nil { + return "", fmt.Errorf("pm dump failed: %w", err) + } + + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "dataDir=") { + return strings.TrimPrefix(line, "dataDir="), nil + } + } + + return "", fmt.Errorf("dataDir not found for package %s", packageName) +} + func (d *AndroidDevice) StartScreenCapture(config ScreenCaptureConfig) error { if config.Format != "mjpeg" && config.Format != "avc" { return fmt.Errorf("unsupported format: %s, only 'mjpeg' and 'avc' are supported", config.Format) diff --git a/devices/android_fs.go b/devices/android_fs.go new file mode 100644 index 0000000..0d98cb0 --- /dev/null +++ b/devices/android_fs.go @@ -0,0 +1,220 @@ +package devices + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" + + "al.essio.dev/pkg/shellescape" + "github.com/google/uuid" +) + +// androidPackageName extracts the package name from a /data/user///... path. +func androidPackageName(remotePath string) (string, error) { + parts := strings.SplitN(remotePath, "/", 6) + if len(parts) < 5 { + return "", fmt.Errorf("invalid /data/user/ path: %s", remotePath) + } + return parts[4], nil +} + +// buildShellCommand returns a single shell-safe command string for `adb shell` or +// `adb exec-out`. every token is quoted so package names and paths containing spaces +// or shell metacharacters cannot escape into the device shell. paths under /data/user/ +// are wrapped with `run-as `. +func (d *AndroidDevice) buildShellCommand(remotePath string, parts ...string) (string, error) { + cmd := shellescape.QuoteCommand(parts) + if !strings.HasPrefix(remotePath, "/data/user/") { + return cmd, nil + } + pkg, err := androidPackageName(remotePath) + if err != nil { + return "", err + } + return "run-as " + shellescape.Quote(pkg) + " " + cmd, nil +} + +func (d *AndroidDevice) PushFile(localPath, remotePath string) error { + if !strings.HasPrefix(remotePath, "/data/user/") { + _, err := d.runAdbCommand("push", localPath, remotePath) + return err + } + + tmpPath := fmt.Sprintf("/data/local/tmp/mobilecli-%s", uuid.NewString()) + if _, err := d.runAdbCommand("push", localPath, tmpPath); err != nil { + return fmt.Errorf("push to tmp failed: %w", err) + } + + cpCmd, err := d.buildShellCommand(remotePath, "cp", tmpPath, remotePath) + if err != nil { + return err + } + _, cpErr := d.runAdbCommand("shell", cpCmd) + + rmCmd := shellescape.QuoteCommand([]string{"rm", tmpPath}) + _, rmErr := d.runAdbCommand("shell", rmCmd) + + if cpErr != nil { + return fmt.Errorf("copy to app container failed: %w", cpErr) + } + if rmErr != nil { + return fmt.Errorf("cleanup of tmp file failed: %w", rmErr) + } + return nil +} + +func (d *AndroidDevice) PullFile(remotePath, localPath string) error { + shellCmd, err := d.buildShellCommand(remotePath, "cat", remotePath) + if err != nil { + return err + } + + // exec-out (instead of shell) bypasses the PTY, preserving binary bytes on Windows + // and keeping stderr separate so we can surface it on failure + deviceID := d.getAdbIdentifier() + cmd := exec.Command(getAdbPath(), "-s", deviceID, "exec-out", shellCmd) + var stderr bytes.Buffer + cmd.Stderr = &stderr + data, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return fmt.Errorf("pull failed: %w: %s", err, msg) + } + return fmt.Errorf("pull failed: %w", err) + } + return os.WriteFile(localPath, data, 0644) +} + +func (d *AndroidDevice) ListFiles(bundleID, remotePath string) ([]FileEntry, error) { + if remotePath == "" { + remotePath = "/" + } + + // LANG=C pins the date format to YYYY-MM-DD HH:MM regardless of device locale. + // trailing slash makes ls follow symlinks (e.g. /sdcard -> /storage/self/primary); + // fall back to the original path if that fails (path is a file, not a dir). + lsPath := strings.TrimRight(remotePath, "/") + "/" + lsCmd, err := d.buildShellCommand(remotePath, "ls", "-la", lsPath) + if err != nil { + return nil, err + } + output, err := d.runAdbCommand("shell", "LANG=C "+lsCmd) + if err != nil { + lsCmd, err = d.buildShellCommand(remotePath, "ls", "-la", remotePath) + if err != nil { + return nil, err + } + output, err = d.runAdbCommand("shell", "LANG=C "+lsCmd) + } + if err != nil { + return nil, fmt.Errorf("ls failed: %w", err) + } + + return androidParseLsOutput(string(output), remotePath), nil +} + +// toybox `ls -la` line under LANG=C: +// +// [ -> ] +// +// the size column is numeric for files; character/block devices show `, ` +// (with optional spaces around the comma). +var androidLsLineRe = regexp.MustCompile( + `^([-dlbcps])\S*\s+\d+\s+\S+\s+\S+\s+(?:\d+,\s*\d+|\d+)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s+(.+)$`, +) + +func androidParseLsOutput(output, dirPath string) []FileEntry { + dirPath = strings.TrimRight(dirPath, "/") + entries := []FileEntry{} + for _, line := range strings.Split(output, "\n") { + line = strings.TrimRight(line, "\r") + if line == "" || strings.HasPrefix(line, "total ") { + continue + } + entry := androidParseLsLine(line, dirPath) + if entry == nil || entry.Name == "." || entry.Name == ".." { + continue + } + entries = append(entries, *entry) + } + return entries +} + +func androidParseLsLine(line, dirPath string) *FileEntry { + m := androidLsLineRe.FindStringSubmatch(line) + if m == nil { + return nil + } + + typeChar := m[1] + isDir := typeChar == "d" + + // re-extract numeric size from the original tokens; we've already validated layout + var size int64 + if !isDir { + fields := strings.Fields(line) + if len(fields) >= 5 { + size, _ = strconv.ParseInt(fields[4], 10, 64) + } + } + + modTime, _ := time.Parse("2006-01-02 15:04", m[2]+" "+m[3]) + + name := m[4] + if typeChar == "l" { + if idx := strings.Index(name, " -> "); idx != -1 { + name = name[:idx] + } + } + + // when listing a single file, ls echoes the absolute path as the name + var entryPath string + if strings.HasPrefix(name, "/") { + entryPath = name + name = name[strings.LastIndex(name, "/")+1:] + } else { + entryPath = strings.TrimRight(dirPath, "/") + "/" + name + } + + return &FileEntry{ + Name: name, + Path: entryPath, + Size: size, + ModTime: modTime, + IsDir: isDir, + } +} + +func (d *AndroidDevice) Mkdir(bundleID, remotePath string, parents bool) error { + parts := []string{"mkdir"} + if parents { + parts = append(parts, "-p") + } + parts = append(parts, remotePath) + cmd, err := d.buildShellCommand(remotePath, parts...) + if err != nil { + return err + } + _, err = d.runAdbCommand("shell", cmd) + return err +} + +func (d *AndroidDevice) Rm(bundleID, remotePath string, recursive bool) error { + parts := []string{"rm"} + if recursive { + parts = append(parts, "-rf") + } + parts = append(parts, remotePath) + cmd, err := d.buildShellCommand(remotePath, parts...) + if err != nil { + return err + } + _, err = d.runAdbCommand("shell", cmd) + return err +} diff --git a/devices/android_fs_test.go b/devices/android_fs_test.go new file mode 100644 index 0000000..29bcfcf --- /dev/null +++ b/devices/android_fs_test.go @@ -0,0 +1,155 @@ +package devices + +import "testing" + +func Test_androidParseLsLine(t *testing.T) { + tests := []struct { + name string + line string + dirPath string + wantName string + wantPath string + wantSize int64 + wantDir bool + wantNil bool + }{ + { + name: "regular file", + line: "-rw-r--r-- 1 root root 1024 2024-01-15 12:34 file.txt", + dirPath: "/sdcard", + wantName: "file.txt", + wantPath: "/sdcard/file.txt", + wantSize: 1024, + }, + { + name: "directory", + line: "drwxr-xr-x 2 root root 4096 2024-01-15 12:34 Download", + dirPath: "/sdcard", + wantName: "Download", + wantPath: "/sdcard/Download", + wantSize: 0, + wantDir: true, + }, + { + name: "symlink with arrow target stripped", + line: "lrwxrwxrwx 1 root root 21 2024-01-15 12:34 sdcard -> /storage/self/primary", + dirPath: "/", + wantName: "sdcard", + wantPath: "/sdcard", + wantSize: 21, + }, + { + name: "character device with major,minor", + line: "crw-rw-rw- 1 root root 1, 3 2024-01-15 12:34 null", + dirPath: "/dev", + wantName: "null", + wantPath: "/dev/null", + wantSize: 0, + }, + { + name: "block device with no space after comma", + line: "brw------- 1 root root 7,0 2024-01-15 12:34 loop0", + dirPath: "/dev/block", + wantName: "loop0", + wantPath: "/dev/block/loop0", + wantSize: 0, + }, + { + name: "filename with spaces preserved", + line: "-rw-r--r-- 1 root root 17 2024-01-15 12:34 my cool file.txt", + dirPath: "/sdcard", + wantName: "my cool file.txt", + wantPath: "/sdcard/my cool file.txt", + wantSize: 17, + }, + { + name: "absolute path in name (single-file ls)", + line: "-rw-r--r-- 1 root root 5 2024-01-15 12:34 /sdcard/Download/a.txt", + dirPath: "/sdcard/Download/a.txt", + wantName: "a.txt", + wantPath: "/sdcard/Download/a.txt", + wantSize: 5, + }, + { + name: "total line", + line: "total 24", + wantNil: true, + }, + { + name: "empty line", + line: "", + wantNil: true, + }, + { + name: "garbage line", + line: "this is not ls output", + wantNil: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := androidParseLsLine(tc.line, tc.dirPath) + if tc.wantNil { + if got != nil { + t.Fatalf("expected nil, got %+v", got) + } + return + } + if got == nil { + t.Fatalf("expected entry, got nil") + } + if got.Name != tc.wantName { + t.Errorf("Name: got %q, want %q", got.Name, tc.wantName) + } + if got.Path != tc.wantPath { + t.Errorf("Path: got %q, want %q", got.Path, tc.wantPath) + } + if got.Size != tc.wantSize { + t.Errorf("Size: got %d, want %d", got.Size, tc.wantSize) + } + if got.IsDir != tc.wantDir { + t.Errorf("IsDir: got %v, want %v", got.IsDir, tc.wantDir) + } + }) + } +} + +func Test_androidParseLsOutput_filtersDotEntries(t *testing.T) { + output := `total 12 +drwxr-xr-x 3 root root 4096 2024-01-15 12:34 . +drwxr-xr-x 9 root root 4096 2024-01-15 12:34 .. +-rw-r--r-- 1 root root 7 2024-01-15 12:34 hello.txt +` + entries := androidParseLsOutput(output, "/sdcard/x") + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d: %+v", len(entries), entries) + } + if entries[0].Name != "hello.txt" { + t.Errorf("got %q, want %q", entries[0].Name, "hello.txt") + } +} + +func Test_buildShellCommand_quotesUntrustedInput(t *testing.T) { + d := &AndroidDevice{} + + cmd, err := d.buildShellCommand("/sdcard/x", "cat", "/sdcard/a;rm -rf /") + if err != nil { + t.Fatal(err) + } + // the dangerous path must be quoted so the device shell sees it as a single arg + want := `cat '/sdcard/a;rm -rf /'` + if cmd != want { + t.Errorf("got %q, want %q", cmd, want) + } + + cmd, err = d.buildShellCommand("/data/user/0/com.evil;reboot/files/x", "cat", "/data/user/0/com.evil;reboot/files/x") + if err != nil { + t.Fatal(err) + } + // the package name `com.evil;reboot` must be quoted inside run-as + wantPrefix := `run-as 'com.evil;reboot' cat ` + if len(cmd) < len(wantPrefix) || cmd[:len(wantPrefix)] != wantPrefix { + t.Errorf("got %q, want prefix %q", cmd, wantPrefix) + } +} diff --git a/devices/common.go b/devices/common.go index 75f0565..6d47577 100644 --- a/devices/common.go +++ b/devices/common.go @@ -94,6 +94,15 @@ type StartAgentConfig struct { type ScreenElementRect = types.ScreenElementRect type ScreenElement = types.ScreenElement +// FileEntry represents a file or directory in an app's container +type FileEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + ModTime time.Time `json:"modTime"` + IsDir bool `json:"isDir"` +} + type ControllableDevice interface { ID() string Name() string @@ -128,6 +137,13 @@ type ControllableDevice interface { SetOrientation(orientation string) error ListCrashReports() ([]CrashReport, error) GetCrashReport(id string) ([]byte, error) + + PushFile(localPath, remotePath string) error + PullFile(remotePath, localPath string) error + ListFiles(bundleID, remotePath string) ([]FileEntry, error) + Mkdir(bundleID, remotePath string, parents bool) error + Rm(bundleID, remotePath string, recursive bool) error + GetAppContainerPath(bundleID string) (string, error) } // GetAllControllableDevices aggregates all known devices with options diff --git a/devices/ios_fs.go b/devices/ios_fs.go new file mode 100644 index 0000000..debdf7b --- /dev/null +++ b/devices/ios_fs.go @@ -0,0 +1,175 @@ +package devices + +import ( + "errors" + "fmt" + "path" + "strings" + + "github.com/danielpaulus/go-ios/ios/afc" + "github.com/danielpaulus/go-ios/ios/house_arrest" + "github.com/danielpaulus/go-ios/ios/installationproxy" +) + +const iosAppContainerPrefix = "/private/var/mobile/Containers/Data/Application/" + +// iosBrowseAllApps returns all installed apps via the installation proxy. +func (d *IOSDevice) iosBrowseAllApps() ([]installationproxy.AppInfo, error) { + device, err := d.getEnhancedDevice() + if err != nil { + return nil, fmt.Errorf("failed to get device: %w", err) + } + svc, err := installationproxy.New(device) + if err != nil { + return nil, fmt.Errorf("installationproxy failed: %w", err) + } + defer svc.Close() + return svc.BrowseAllApps() +} + +// resolveAfcClientAndPath returns the right AFC client and normalized path. +// App container absolute paths are transparently routed through House Arrest. +func (d *IOSDevice) resolveAfcClientAndPath(bundleID, remotePath string) (*afc.Client, string, error) { + device, err := d.getEnhancedDevice() + if err != nil { + return nil, "", fmt.Errorf("failed to get device: %w", err) + } + + if bundleID != "" { + client, err := house_arrest.New(device, bundleID) + return client, remotePath, err + } + + // detect absolute app container path and route through House Arrest + if strings.HasPrefix(remotePath, iosAppContainerPrefix) { + rest := strings.TrimPrefix(remotePath, iosAppContainerPrefix) + parts := strings.SplitN(rest, "/", 2) + uuid := parts[0] + containerRelPath := "/" + if len(parts) == 2 && parts[1] != "" { + containerRelPath = "/" + parts[1] + } + + resolvedBundleID, err := d.bundleIDForContainerUUID(uuid) + if err != nil { + return nil, "", fmt.Errorf("cannot access app container (use bundle-id instead): %w", err) + } + client, err := house_arrest.New(device, resolvedBundleID) + return client, containerRelPath, err + } + + client, err := afc.New(device) + return client, remotePath, err +} + +// bundleIDForContainerUUID looks up which app owns a given data container UUID. +func (d *IOSDevice) bundleIDForContainerUUID(uuid string) (string, error) { + apps, err := d.iosBrowseAllApps() + if err != nil { + return "", err + } + for _, app := range apps { + container, ok := app["Container"].(string) + if !ok { + continue + } + if strings.HasSuffix(strings.TrimRight(container, "/"), uuid) { + return app.CFBundleIdentifier(), nil + } + } + return "", fmt.Errorf("no app found with container UUID %s", uuid) +} + +func (d *IOSDevice) GetAppContainerPath(bundleID string) (string, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if err := d.startTunnel(); err != nil { + return "", fmt.Errorf("failed to start tunnel: %w", err) + } + + apps, err := d.iosBrowseAllApps() + if err != nil { + return "", err + } + + for _, app := range apps { + if app.CFBundleIdentifier() == bundleID { + if container, ok := app["Container"].(string); ok { + return container, nil + } + return "", fmt.Errorf("app %s has no data container (system app?)", bundleID) + } + } + + return "", fmt.Errorf("app %s not found on device", bundleID) +} + +func (d *IOSDevice) ListFiles(bundleID, remotePath string) ([]FileEntry, error) { + if remotePath == "" { + remotePath = "/" + } + + if err := func() error { + d.mu.Lock() + defer d.mu.Unlock() + return d.startTunnel() + }(); err != nil { + return nil, fmt.Errorf("failed to start tunnel: %w", err) + } + + client, remotePath, err := d.resolveAfcClientAndPath(bundleID, remotePath) + if err != nil { + return nil, fmt.Errorf("afc connect failed: %w", err) + } + defer client.Close() + + names, err := client.List(remotePath) + if err != nil { + // path might be a single file — stat it directly + info, statErr := client.Stat(remotePath) + if statErr != nil { + return nil, fmt.Errorf("ls failed: %w", err) + } + size := int64(0) + if !info.IsDir() { + size = info.Size + } + return []FileEntry{{ + Name: path.Base(remotePath), + Path: remotePath, + Size: size, + IsDir: info.IsDir(), + }}, nil + } + + entries := make([]FileEntry, 0, len(names)) + for _, name := range names { + fullPath := strings.TrimRight(remotePath, "/") + "/" + name + entry := FileEntry{Name: name, Path: fullPath} + if info, err := client.Stat(fullPath); err == nil { + entry.IsDir = info.IsDir() + if !entry.IsDir { + entry.Size = info.Size + } + } + entries = append(entries, entry) + } + return entries, nil +} + +func (d *IOSDevice) PushFile(localPath, remotePath string) error { + return errors.New("not implemented") +} + +func (d *IOSDevice) PullFile(remotePath, localPath string) error { + return errors.New("not implemented") +} + +func (d *IOSDevice) Mkdir(bundleID, remotePath string, parents bool) error { + return errors.New("not implemented") +} + +func (d *IOSDevice) Rm(bundleID, remotePath string, recursive bool) error { + return errors.New("not implemented") +} diff --git a/devices/remote_fs.go b/devices/remote_fs.go new file mode 100644 index 0000000..3d0a4fc --- /dev/null +++ b/devices/remote_fs.go @@ -0,0 +1,27 @@ +package devices + +import "errors" + +func (r *RemoteDevice) PushFile(localPath, remotePath string) error { + return errors.New("not implemented") +} + +func (r *RemoteDevice) PullFile(remotePath, localPath string) error { + return errors.New("not implemented") +} + +func (r *RemoteDevice) ListFiles(bundleID, remotePath string) ([]FileEntry, error) { + return nil, errors.New("not implemented") +} + +func (r *RemoteDevice) Mkdir(bundleID, remotePath string, parents bool) error { + return errors.New("not implemented") +} + +func (r *RemoteDevice) Rm(bundleID, remotePath string, recursive bool) error { + return errors.New("not implemented") +} + +func (r *RemoteDevice) GetAppContainerPath(bundleID string) (string, error) { + return "", errors.New("not implemented") +} diff --git a/devices/simulator_fs.go b/devices/simulator_fs.go new file mode 100644 index 0000000..6aaf074 --- /dev/null +++ b/devices/simulator_fs.go @@ -0,0 +1,139 @@ +package devices + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// simulatorDeviceRoot returns the CoreSimulator device directory for this simulator. +// All allowed paths must be within this root to prevent accidental Mac filesystem access. +func (s *SimulatorDevice) simulatorDeviceRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + return filepath.Join(home, "Library", "Developer", "CoreSimulator", "Devices", s.UDID), nil +} + +// validatePath ensures the given path is within the simulator's device directory. +func (s *SimulatorDevice) validatePath(path string) error { + root, err := s.simulatorDeviceRoot() + if err != nil { + return err + } + clean := filepath.Clean(path) + if clean != root && !strings.HasPrefix(clean, root+string(filepath.Separator)) { + return fmt.Errorf("path '%s' is outside the simulator device directory", path) + } + return nil +} + +func (s *SimulatorDevice) GetAppContainerPath(bundleID string) (string, error) { + output, err := runSimctl("get_app_container", s.UDID, bundleID, "data") + if err != nil { + return "", fmt.Errorf("get_app_container failed: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +func (s *SimulatorDevice) ListFiles(bundleID, remotePath string) ([]FileEntry, error) { + if remotePath == "" { + if bundleID != "" { + var err error + remotePath, err = s.GetAppContainerPath(bundleID) + if err != nil { + return nil, err + } + } else { + root, err := s.simulatorDeviceRoot() + if err != nil { + return nil, err + } + remotePath = filepath.Join(root, "data") + } + } + + if err := s.validatePath(remotePath); err != nil { + return nil, err + } + + dirEntries, err := os.ReadDir(remotePath) + if err != nil { + // path might be a single file + info, statErr := os.Stat(remotePath) + if statErr != nil { + return nil, fmt.Errorf("ls failed: %w", err) + } + return []FileEntry{{ + Name: filepath.Base(remotePath), + Path: remotePath, + Size: info.Size(), + ModTime: info.ModTime(), + IsDir: false, + }}, nil + } + + entries := make([]FileEntry, 0, len(dirEntries)) + for _, de := range dirEntries { + info, err := de.Info() + if err != nil { + continue + } + size := info.Size() + if de.IsDir() { + size = 0 + } + entries = append(entries, FileEntry{ + Name: de.Name(), + Path: filepath.Join(remotePath, de.Name()), + Size: size, + ModTime: info.ModTime(), + IsDir: de.IsDir(), + }) + } + return entries, nil +} + +func (s *SimulatorDevice) PullFile(remotePath, localPath string) error { + if err := s.validatePath(remotePath); err != nil { + return err + } + data, err := os.ReadFile(remotePath) + if err != nil { + return fmt.Errorf("pull failed: %w", err) + } + return os.WriteFile(localPath, data, 0644) +} + +func (s *SimulatorDevice) PushFile(localPath, remotePath string) error { + if err := s.validatePath(remotePath); err != nil { + return err + } + data, err := os.ReadFile(localPath) + if err != nil { + return fmt.Errorf("read local file failed: %w", err) + } + return os.WriteFile(remotePath, data, 0644) +} + +func (s *SimulatorDevice) Mkdir(bundleID, remotePath string, parents bool) error { + if err := s.validatePath(remotePath); err != nil { + return err + } + if parents { + return os.MkdirAll(remotePath, 0755) + } + return os.Mkdir(remotePath, 0755) +} + +func (s *SimulatorDevice) Rm(bundleID, remotePath string, recursive bool) error { + if err := s.validatePath(remotePath); err != nil { + return err + } + if recursive { + return os.RemoveAll(remotePath) + } + return os.Remove(remotePath) +} diff --git a/server/dispatch.go b/server/dispatch.go index 8404435..183516c 100644 --- a/server/dispatch.go +++ b/server/dispatch.go @@ -35,6 +35,12 @@ func GetMethodRegistry() map[string]HandlerFunc { "device.apps.foreground": handleAppsForeground, "device.apps.install": handleAppsInstall, "device.apps.uninstall": handleAppsUninstall, + "device.apps.path": handleAppsPath, + "device.fs.ls": handleFsLs, + "device.fs.pull": handleFsPull, + "device.fs.push": handleFsPush, + "device.fs.mkdir": handleFsMkdir, + "device.fs.rm": handleFsRm, "device.screenrecord": handleScreenRecord, "device.screenrecord.stop": handleScreenRecordStop, "device.crashes.list": handleCrashesList, diff --git a/server/server.go b/server/server.go index 84a0a89..35f9af1 100644 --- a/server/server.go +++ b/server/server.go @@ -2,12 +2,14 @@ package server import ( "context" + "encoding/base64" "encoding/json" "fmt" "log" "net/http" "os" "os/signal" + "path" "strconv" "strings" "sync" @@ -1482,3 +1484,225 @@ func handleScreenCapture(r *http.Request, w http.ResponseWriter, params json.Raw return nil } + +const fsSizeLimit = 1 << 20 // 1 MB + +type AppsPathParams struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` +} + +type FsLsParams struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + RemotePath string `json:"remotePath"` +} + +type FsPullParams struct { + DeviceID string `json:"deviceId"` + RemotePath string `json:"remotePath"` +} + +type FsPushParams struct { + DeviceID string `json:"deviceId"` + RemotePath string `json:"remotePath"` + Content string `json:"content"` // base64-encoded file contents +} + +type FsMkdirParams struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + RemotePath string `json:"remotePath"` + Parents bool `json:"parents"` +} + +type FsRmParams struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + RemotePath string `json:"remotePath"` + Recursive bool `json:"recursive"` +} + +func handleAppsPath(params json.RawMessage) (any, error) { + var p AppsPathParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid parameters: %w", err) + } + if p.BundleID == "" { + return nil, fmt.Errorf("'bundleId' is required") + } + + response := commands.AppPathCommand(commands.AppPathRequest{ + DeviceID: p.DeviceID, + BundleID: p.BundleID, + }) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + return response.Data, nil +} + +func handleFsLs(params json.RawMessage) (any, error) { + var p FsLsParams + if len(params) > 0 { + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid parameters: %w", err) + } + } + + response := commands.FsListCommand(commands.FsListRequest{ + DeviceID: p.DeviceID, + BundleID: p.BundleID, + RemotePath: p.RemotePath, + }) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + return response.Data, nil +} + +func handleFsPull(params json.RawMessage) (any, error) { + var p FsPullParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid parameters: %w", err) + } + if p.RemotePath == "" { + return nil, fmt.Errorf("'remotePath' is required") + } + + // stat the file first via a single-path ListFiles so we can reject oversized + // transfers before pulling the bytes from the device. + statResp := commands.FsListCommand(commands.FsListRequest{ + DeviceID: p.DeviceID, + RemotePath: p.RemotePath, + }) + if statResp.Status == "error" { + return nil, fmt.Errorf("%s", statResp.Error) + } + if entries, ok := statResp.Data.([]devices.FileEntry); ok && len(entries) == 1 { + e := entries[0] + if path.Clean(e.Path) == path.Clean(p.RemotePath) { + if e.IsDir { + return nil, fmt.Errorf("path is a directory: %s", p.RemotePath) + } + if e.Size > fsSizeLimit { + return nil, fmt.Errorf("file too large (%d bytes); maximum allowed size for JSON-RPC transfer is 1 MB", e.Size) + } + } + } + + tmp, err := os.CreateTemp("", "mobilecli-pull-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmp.Name() + tmp.Close() + defer os.Remove(tmpPath) + + response := commands.FsPullCommand(commands.FsPullRequest{ + DeviceID: p.DeviceID, + RemotePath: p.RemotePath, + LocalPath: tmpPath, + }) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + + data, err := os.ReadFile(tmpPath) + if err != nil { + return nil, fmt.Errorf("failed to read pulled file: %w", err) + } + if len(data) > fsSizeLimit { + return nil, fmt.Errorf("file too large (%d bytes); maximum allowed size for JSON-RPC transfer is 1 MB", len(data)) + } + + return map[string]any{ + "content": base64.StdEncoding.EncodeToString(data), + "size": len(data), + }, nil +} + +func handleFsPush(params json.RawMessage) (any, error) { + var p FsPushParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid parameters: %w", err) + } + if p.RemotePath == "" { + return nil, fmt.Errorf("'remotePath' is required") + } + if p.Content == "" { + return nil, fmt.Errorf("'content' is required") + } + + data, err := base64.StdEncoding.DecodeString(p.Content) + if err != nil { + return nil, fmt.Errorf("'content' is not valid base64: %w", err) + } + if len(data) > fsSizeLimit { + return nil, fmt.Errorf("file too large (%d bytes); maximum allowed size for JSON-RPC transfer is 1 MB", len(data)) + } + + tmp, err := os.CreateTemp("", "mobilecli-push-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return nil, fmt.Errorf("failed to write temp file: %w", err) + } + tmp.Close() + + response := commands.FsPushCommand(commands.FsPushRequest{ + DeviceID: p.DeviceID, + LocalPath: tmpPath, + RemotePath: p.RemotePath, + }) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + return response.Data, nil +} + +func handleFsMkdir(params json.RawMessage) (any, error) { + var p FsMkdirParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid parameters: %w", err) + } + if p.RemotePath == "" { + return nil, fmt.Errorf("'remotePath' is required") + } + + response := commands.FsMkdirCommand(commands.FsMkdirRequest{ + DeviceID: p.DeviceID, + BundleID: p.BundleID, + RemotePath: p.RemotePath, + Parents: p.Parents, + }) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + return response.Data, nil +} + +func handleFsRm(params json.RawMessage) (any, error) { + var p FsRmParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid parameters: %w", err) + } + if p.RemotePath == "" { + return nil, fmt.Errorf("'remotePath' is required") + } + + response := commands.FsRmCommand(commands.FsRmRequest{ + DeviceID: p.DeviceID, + BundleID: p.BundleID, + RemotePath: p.RemotePath, + Recursive: p.Recursive, + }) + if response.Status == "error" { + return nil, fmt.Errorf("%s", response.Error) + } + return response.Data, nil +} diff --git a/test/emulator.ts b/test/emulator.ts index 6cb1679..5c5dd55 100644 --- a/test/emulator.ts +++ b/test/emulator.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { execSync } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; +import * as os from 'os'; import { createAndLaunchEmulator, shutdownEmulator, @@ -90,6 +91,119 @@ describe('Android Emulator Tests', () => { getDeviceInfo(deviceId); }); + + describe('fs operations on /sdcard/Download', () => { + const remoteDir = '/sdcard/Download/mobilecli-test'; + const remoteFile = `${remoteDir}/hello.txt`; + + it('should create a nested directory with mkdir -p', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + fsMkdir(deviceId, remoteDir, true); + }); + + it('should push a file into /sdcard/Download', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + const localFile = writeTempFile('hello from mobilecli'); + fsPush(deviceId, localFile, remoteFile); + fs.unlinkSync(localFile); + }); + + it('should list the pushed file in /sdcard/Download', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + const entries = fsList(deviceId, remoteDir); + const names = entries.map((e: any) => e.name); + expect(names).to.include('hello.txt'); + }); + + it('should pull the file back and verify contents match', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + const localDest = path.join(os.tmpdir(), `mobilecli-pull-${Date.now()}.txt`); + fsPull(deviceId, remoteFile, localDest); + const contents = fs.readFileSync(localDest, 'utf8'); + expect(contents.trim()).to.equal('hello from mobilecli'); + fs.unlinkSync(localDest); + }); + + it('should remove the test directory recursively', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + fsRm(deviceId, remoteDir, true); + const entries = fsList(deviceId, '/sdcard/Download'); + const names = entries.map((e: any) => e.name); + expect(names).to.not.include('mobilecli-test'); + }); + }); + + describe('fs operations on app container (com.mobilenext.mobilewright_demo)', () => { + const packageName = 'com.mobilenext.mobilewright_demo'; + let containerPath: string; + let remoteDir: string; + let remoteFile: string; + + before(function () { + if (!systemImageAvailable) return; + containerPath = getAppContainerPath(deviceId, packageName); + remoteDir = `${containerPath}/files/mobilecli-test`; + remoteFile = `${remoteDir}/data.txt`; + }); + + it('should return a valid container path for com.mobilenext.mobilewright_demo', function () { + if (!systemImageAvailable) { this.skip(); return; } + expect(containerPath).to.match(/^\/data\/user\/\d+\/com\.mobilenext\.mobilewright_demo/); + }); + + it('should list the app container root', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + const entries = fsList(deviceId, containerPath); + expect(entries).to.be.an('array'); + }); + + it('should create a directory inside the app container', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + fsMkdir(deviceId, remoteDir, true); + }); + + it('should push a file into the app container', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + const localFile = writeTempFile('app container test'); + fsPush(deviceId, localFile, remoteFile); + fs.unlinkSync(localFile); + }); + + it('should list the file inside the app container', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + const entries = fsList(deviceId, remoteDir); + const names = entries.map((e: any) => e.name); + expect(names).to.include('data.txt'); + }); + + it('should pull the file from the app container and verify contents', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + const localDest = path.join(os.tmpdir(), `mobilecli-pull-app-${Date.now()}.txt`); + fsPull(deviceId, remoteFile, localDest); + const contents = fs.readFileSync(localDest, 'utf8'); + expect(contents.trim()).to.equal('app container test'); + fs.unlinkSync(localDest); + }); + + it('should remove the test directory from the app container', function () { + if (!systemImageAvailable) { this.skip(); return; } + this.timeout(30000); + fsRm(deviceId, remoteDir, true); + const entries = fsList(deviceId, `${containerPath}/files`); + const names = entries.map((e: any) => e.name); + expect(names).to.not.include('mobilecli-test'); + }); + }); }); }); }); @@ -146,3 +260,50 @@ function openUrl(deviceId: string, url: string): void { function getDeviceInfo(deviceId: string): void { mobilecli(`device info --device ${deviceId}`); } + +function mobilecliJson(args: string): any { + const mobilecliBinary = path.join(__dirname, '..', 'mobilecli'); + const result = execSync(`${mobilecliBinary} ${args}`, { + encoding: 'utf8', + timeout: 60000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ANDROID_HOME: process.env.ANDROID_HOME || '' }, + }); + return JSON.parse(result); +} + +function getAppContainerPath(deviceId: string, packageName: string): string { + const response = mobilecliJson(`apps path ${packageName} --device ${deviceId}`); + expect(response.status).to.equal('ok'); + return response.data.path; +} + +function fsList(deviceId: string, remotePath: string): any[] { + const response = mobilecliJson(`fs ls --device ${deviceId} "${remotePath}"`); + expect(response.status).to.equal('ok'); + return response.data; +} + +function fsPush(deviceId: string, localPath: string, remotePath: string): void { + mobilecli(`fs push --device ${deviceId} "${localPath}" "${remotePath}"`); +} + +function fsPull(deviceId: string, remotePath: string, localPath: string): void { + mobilecli(`fs pull --device ${deviceId} "${remotePath}" "${localPath}"`); +} + +function fsMkdir(deviceId: string, remotePath: string, parents: boolean): void { + const flag = parents ? '-p ' : ''; + mobilecli(`fs mkdir --device ${deviceId} ${flag}"${remotePath}"`); +} + +function fsRm(deviceId: string, remotePath: string, recursive: boolean): void { + const flag = recursive ? '-r ' : ''; + mobilecli(`fs rm --device ${deviceId} ${flag}"${remotePath}"`); +} + +function writeTempFile(content: string): string { + const tmpPath = path.join(os.tmpdir(), `mobilecli-push-${Date.now()}.txt`); + fs.writeFileSync(tmpPath, content, 'utf8'); + return tmpPath; +}