From c057ae1c633ce87ba8e4bd7a82eb32892aba6d1a Mon Sep 17 00:00:00 2001 From: gmegidish Date: Mon, 11 May 2026 21:31:52 +0200 Subject: [PATCH 01/18] feat: add app filesystem access commands (apps fs, apps path) Adds ControllableDevice interface methods for app container filesystem operations (push, pull, ls, mkdir, rm, get-container-path) with working Android implementation for ls and path, and stubs for iOS, simulator, and remote devices. --- cli/apps.go | 21 ++++++ cli/fs.go | 139 +++++++++++++++++++++++++++++++++++++ commands/apps.go | 25 +++++++ commands/fs.go | 147 ++++++++++++++++++++++++++++++++++++++++ devices/android.go | 22 +++++- devices/android_fs.go | 118 ++++++++++++++++++++++++++++++++ devices/common.go | 16 +++++ devices/ios_fs.go | 27 ++++++++ devices/remote_fs.go | 27 ++++++++ devices/simulator_fs.go | 27 ++++++++ 10 files changed, 566 insertions(+), 3 deletions(-) create mode 100644 cli/fs.go create mode 100644 commands/fs.go create mode 100644 devices/android_fs.go create mode 100644 devices/ios_fs.go create mode 100644 devices/remote_fs.go create mode 100644 devices/simulator_fs.go 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..9735b7d --- /dev/null +++ b/cli/fs.go @@ -0,0 +1,139 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/mobile-next/mobilecli/commands" + "github.com/spf13/cobra" +) + +var appsFsCmd = &cobra.Command{ + Use: "fs", + Short: "Access app container filesystem", + Long: `Push, pull, list, and manage files within an app's container filesystem.`, +} + +var appsFsPushCmd = &cobra.Command{ + Use: "push ", + Short: "Push a file into an app's container", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + req := commands.FsPushRequest{ + DeviceID: deviceId, + BundleID: args[0], + LocalPath: args[1], + RemotePath: args[2], + } + response := commands.FsPushCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var appsFsPullCmd = &cobra.Command{ + Use: "pull ", + Short: "Pull a file from an app's container", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + req := commands.FsPullRequest{ + DeviceID: deviceId, + BundleID: args[0], + RemotePath: args[1], + LocalPath: args[2], + } + response := commands.FsPullCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var appsFsLsCmd = &cobra.Command{ + Use: "ls [bundle-id] [remote-path]", + Short: "List files in an app's container or at an absolute path", + Args: cobra.RangeArgs(0, 2), + RunE: func(cmd *cobra.Command, args []string) error { + var bundleID, remotePath string + if len(args) == 0 { + remotePath = "/" + } else 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 appsFsMkdirCmd = &cobra.Command{ + Use: "mkdir ", + Short: "Create a directory in an app's container", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + req := commands.FsMkdirRequest{ + DeviceID: deviceId, + BundleID: args[0], + RemotePath: args[1], + } + response := commands.FsMkdirCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var appsFsRmCmd = &cobra.Command{ + Use: "rm ", + Short: "Remove a file or directory (recursive) from an app's container", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + req := commands.FsRmRequest{ + DeviceID: deviceId, + BundleID: args[0], + RemotePath: args[1], + } + response := commands.FsRmCommand(req) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +func init() { + appsCmd.AddCommand(appsFsCmd) + + appsFsCmd.AddCommand(appsFsPushCmd) + appsFsCmd.AddCommand(appsFsPullCmd) + appsFsCmd.AddCommand(appsFsLsCmd) + appsFsCmd.AddCommand(appsFsMkdirCmd) + appsFsCmd.AddCommand(appsFsRmCmd) + + appsFsPushCmd.Flags().StringVar(&deviceId, "device", "", "ID of the target device") + appsFsPullCmd.Flags().StringVar(&deviceId, "device", "", "ID of the target device") + appsFsLsCmd.Flags().StringVar(&deviceId, "device", "", "ID of the target device") + appsFsMkdirCmd.Flags().StringVar(&deviceId, "device", "", "ID of the target device") + appsFsRmCmd.Flags().StringVar(&deviceId, "device", "", "ID of the target device") +} diff --git a/commands/apps.go b/commands/apps.go index 2d0203c..2dc6634 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")) + } + + targetDevice, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %v", err)) + } + + path, err := targetDevice.GetAppContainerPath(req.BundleID) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to get app path on device %s: %v", targetDevice.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..7bd0ff6 --- /dev/null +++ b/commands/fs.go @@ -0,0 +1,147 @@ +package commands + +import "fmt" + +type FsPushRequest struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + LocalPath string `json:"localPath"` + RemotePath string `json:"remotePath"` +} + +func FsPushCommand(req FsPushRequest) *CommandResponse { + if req.BundleID == "" { + return NewErrorResponse(fmt.Errorf("bundle ID is required")) + } + if req.LocalPath == "" { + return NewErrorResponse(fmt.Errorf("local path is required")) + } + 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: %v", err)) + } + + if err := device.PushFile(req.BundleID, req.LocalPath, req.RemotePath); err != nil { + return NewErrorResponse(fmt.Errorf("failed to push file: %v", err)) + } + + return NewSuccessResponse(map[string]any{ + "message": fmt.Sprintf("Pushed '%s' to '%s' on app '%s'", req.LocalPath, req.RemotePath, req.BundleID), + }) +} + +type FsPullRequest struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + RemotePath string `json:"remotePath"` + LocalPath string `json:"localPath"` +} + +func FsPullCommand(req FsPullRequest) *CommandResponse { + if req.BundleID == "" { + return NewErrorResponse(fmt.Errorf("bundle ID is required")) + } + 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: %v", err)) + } + + if err := device.PullFile(req.BundleID, req.RemotePath, req.LocalPath); err != nil { + return NewErrorResponse(fmt.Errorf("failed to pull file: %v", err)) + } + + return NewSuccessResponse(map[string]any{ + "message": fmt.Sprintf("Pulled '%s' to '%s' from app '%s'", req.RemotePath, req.LocalPath, req.BundleID), + }) +} + +type FsListRequest struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + RemotePath string `json:"remotePath"` +} + +func FsListCommand(req FsListRequest) *CommandResponse { + if req.BundleID == "" && req.RemotePath == "" { + return NewErrorResponse(fmt.Errorf("bundle ID or remote path is required")) + } + + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %v", err)) + } + + entries, err := device.ListFiles(req.BundleID, req.RemotePath) + if err != nil { + return NewErrorResponse(fmt.Errorf("failed to list files: %v", err)) + } + + return NewSuccessResponse(entries) +} + +type FsMkdirRequest struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + RemotePath string `json:"remotePath"` +} + +func FsMkdirCommand(req FsMkdirRequest) *CommandResponse { + if req.BundleID == "" { + return NewErrorResponse(fmt.Errorf("bundle ID is required")) + } + 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: %v", err)) + } + + if err := device.Mkdir(req.BundleID, req.RemotePath); err != nil { + return NewErrorResponse(fmt.Errorf("failed to create directory: %v", err)) + } + + return NewSuccessResponse(map[string]any{ + "message": fmt.Sprintf("Created directory '%s' in app '%s'", req.RemotePath, req.BundleID), + }) +} + +type FsRmRequest struct { + DeviceID string `json:"deviceId"` + BundleID string `json:"bundleId"` + RemotePath string `json:"remotePath"` +} + +func FsRmCommand(req FsRmRequest) *CommandResponse { + if req.BundleID == "" { + return NewErrorResponse(fmt.Errorf("bundle ID is required")) + } + 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: %v", err)) + } + + if err := device.Rm(req.BundleID, req.RemotePath); err != nil { + return NewErrorResponse(fmt.Errorf("failed to remove: %v", err)) + } + + return NewSuccessResponse(map[string]any{ + "message": fmt.Sprintf("Removed '%s' from app '%s'", req.RemotePath, req.BundleID), + }) +} diff --git a/devices/android.go b/devices/android.go index 59b1653..2510137 100644 --- a/devices/android.go +++ b/devices/android.go @@ -945,13 +945,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..731fe80 --- /dev/null +++ b/devices/android_fs.go @@ -0,0 +1,118 @@ +package devices + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +func (d *AndroidDevice) PushFile(bundleID, localPath, remotePath string) error { + return errors.New("not implemented") +} + +func (d *AndroidDevice) PullFile(bundleID, remotePath, localPath string) error { + return errors.New("not implemented") +} + +func (d *AndroidDevice) ListFiles(bundleID, remotePath string) ([]FileEntry, error) { + var output []byte + var err error + + // 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, "/") + "/" + + if strings.HasPrefix(remotePath, "/data/user/") { + // path structure: /data/user///... + // extract package name as the 5th segment + parts := strings.SplitN(remotePath, "/", 6) + if len(parts) < 5 { + return nil, fmt.Errorf("invalid /data/user/ path: %s", remotePath) + } + packageName := parts[4] + output, err = d.runAdbCommand("shell", "run-as", packageName, "ls", "-la", lsPath) + if err != nil { + output, err = d.runAdbCommand("shell", "run-as", packageName, "ls", "-la", remotePath) + } + } else { + output, err = d.runAdbCommand("shell", "ls", "-la", lsPath) + if err != nil { + output, err = d.runAdbCommand("shell", "ls", "-la", remotePath) + } + } + + if err != nil { + return nil, fmt.Errorf("ls failed: %w", err) + } + + return parseLsOutput(string(output), remotePath), nil +} + +func parseLsOutput(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 := parseLsLine(line, dirPath) + if entry == nil || entry.Name == "." || entry.Name == ".." { + continue + } + entries = append(entries, *entry) + } + if entries == nil { + entries = []FileEntry{} + } + return entries +} + +// parseLsLine parses a single line of Android ls -la output. +// Expected format: