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..32264e4 --- /dev/null +++ b/cli/fs.go @@ -0,0 +1,158 @@ +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 + if len(args) == 1 && strings.HasPrefix(args[0], "/") { + remotePath = args[0] + } else { + bundleID = args[0] + if len(args) == 2 { + 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) + + fsPushCmd.Flags().StringVar(&deviceId, "device", "", "ID of the target device") + fsPullCmd.Flags().StringVar(&deviceId, "device", "", "ID of the target device") + fsLsCmd.Flags().StringVar(&deviceId, "device", "", "ID of the target device") + fsMkdirCmd.Flags().StringVar(&deviceId, "device", "", "ID of the target device") + fsMkdirCmd.Flags().BoolVarP(&fsMkdirParents, "parents", "p", false, "Create parent directories as needed") + fsRmCmd.Flags().StringVar(&deviceId, "device", "", "ID of the target device") + 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..e9fc316 --- /dev/null +++ b/commands/fs.go @@ -0,0 +1,138 @@ +package commands + +import ( + "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 { + return NewErrorResponse(fmt.Errorf("local file not found: %s", req.LocalPath)) + } + + 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 dd81771..2afe63c 100644 --- a/devices/android.go +++ b/devices/android.go @@ -147,6 +147,15 @@ func (d *AndroidDevice) runAdbCommand(args ...string) ([]byte, error) { return cmd.CombinedOutput() } +// runAdbCommandStdout is like runAdbCommand but captures stdout only. +// Use this for binary data where stderr must not contaminate the output. +func (d *AndroidDevice) runAdbCommandStdout(args ...string) ([]byte, error) { + deviceID := d.getAdbIdentifier() + cmdArgs := append([]string{"-s", deviceID}, args...) + cmd := exec.Command(getAdbPath(), cmdArgs...) + return cmd.Output() +} + // getDisplayCount counts the number of displays on the device func (d *AndroidDevice) getDisplayCount() int { output, err := d.runAdbCommand("shell", "dumpsys", "SurfaceFlinger", "--display-id") @@ -945,13 +954,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..b4696f7 --- /dev/null +++ b/devices/android_fs.go @@ -0,0 +1,192 @@ +package devices + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "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 +} + +// runAsArgs returns the adb shell prefix for the given path. +// App container paths under /data/user/ are prefixed with run-as . +func (d *AndroidDevice) runAsArgs(remotePath string) ([]string, error) { + if !strings.HasPrefix(remotePath, "/data/user/") { + return []string{"shell"}, nil + } + pkg, err := androidPackageName(remotePath) + if err != nil { + return nil, err + } + return []string{"shell", "run-as", pkg}, nil +} + +func (d *AndroidDevice) PushFile(localPath, remotePath string) error { + if !strings.HasPrefix(remotePath, "/data/user/") { + _, err := d.runAdbCommand("push", localPath, remotePath) + return err + } + + shellArgs, err := d.runAsArgs(remotePath) + if err != nil { + 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) + } + + _, cpErr := d.runAdbCommand(append(shellArgs, "cp", tmpPath, remotePath)...) + _, rmErr := d.runAdbCommand("shell", "rm", tmpPath) + + 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 { + shellArgs, err := d.runAsArgs(remotePath) + if err != nil { + return err + } + // exec-out instead of shell avoids PTY CRLF translation on Windows + // replace "shell" with "exec-out" so the rest of the args are forwarded as-is + pullArgs := append([]string{"exec-out"}, shellArgs[1:]...) + data, err := d.runAdbCommandStdout(append(pullArgs, "cat", remotePath)...) + if err != nil { + 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 = "/" + } + + shellArgs, err := d.runAsArgs(remotePath) + if err != nil { + return nil, err + } + + // append trailing slash so symlinks (e.g. /sdcard) are followed; + // fall back to the original path if that fails (path is a file, not a dir) + lsPath := strings.TrimRight(remotePath, "/") + "/" + output, err := d.runAdbCommand(append(shellArgs, "ls", "-la", lsPath)...) + if err != nil { + output, err = d.runAdbCommand(append(shellArgs, "ls", "-la", remotePath)...) + } + + if err != nil { + return nil, fmt.Errorf("ls failed: %w", err) + } + + return androidParseLsOutput(string(output), remotePath), nil +} + +func androidParseLsOutput(output, dirPath string) []FileEntry { + dirPath = strings.TrimRight(dirPath, "/") + var entries []FileEntry + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "total ") { + continue + } + entry := androidParseLsLine(line, dirPath) + if entry == nil || entry.Name == "." || entry.Name == ".." { + continue + } + entries = append(entries, *entry) + } + if entries == nil { + entries = []FileEntry{} + } + return entries +} + +// androidParseLsLine parses a single line of Android ls -la output. +// Expected format: