Skip to content

Commit

Permalink
add some errors, add generic methods to extract stuff from maps and c… (
Browse files Browse the repository at this point in the history
#333)

* add some errors, add generic methods to extract stuff from maps and convert arrays

* small testcase

* change signature and name

* remove map function again

* add some comments

* Update ios/appservice/appservice.go

Co-authored-by: dmissmann <[email protected]>

* Update ios/appservice/appservice.go

Co-authored-by: dmissmann <[email protected]>

* change pid to int

---------

Co-authored-by: dmissmann <[email protected]>
  • Loading branch information
danielpaulus and dmissmann authored Feb 6, 2024
1 parent 110f8f0 commit e2ea862
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 67 deletions.
149 changes: 93 additions & 56 deletions ios/appservice/appservice.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Package appservice provides functions to Launch and Kill apps on an iOS devices for iOS17+.
package appservice

import (
Expand All @@ -16,119 +17,145 @@ import (
"howett.net/plist"
)

// Connection represents a connection to the appservice on an iOS device for iOS17+.
// It is used to launch and kill apps and to list processes.
type Connection struct {
conn *xpc.Connection
deviceId string
}

const (
RebootFull = "full"
// RebootFull is the style for a full reboot of the device.
RebootFull = "full"
// RebootUserspace is the style for a reboot of the userspace of the device.
RebootUserspace = "userspace"
)

// New creates a new connection to the appservice on the device for iOS17+.
// It returns an error if the connection could not be established.
func New(deviceEntry ios.DeviceEntry) (*Connection, error) {
xpcConn, err := ios.ConnectToXpcServiceTunnelIface(deviceEntry, "com.apple.coredevice.appservice")
if err != nil {
return nil, err
return nil, fmt.Errorf("new: %w", err)
}

return &Connection{conn: xpcConn, deviceId: uuid.New().String()}, nil
}

// AppLaunch represents the result of launching an app on the device for iOS17+.
// It contains the PID of the launched app.
type AppLaunch struct {
Pid int64
Pid int
}

// Process represents a process running on the device for iOS17+.
// It contains the PID and the path of the process.
type Process struct {
Pid int
Path string
}

// LaunchApp launches an app on the device with the given bundleId and arguments for iOS17+.
func (c *Connection) LaunchApp(bundleId string, args []interface{}, env map[string]interface{}, options map[string]interface{}, terminateExisting bool) (AppLaunch, error) {
msg := buildAppLaunchPayload(c.deviceId, bundleId, args, env, options, terminateExisting)
err := c.conn.Send(msg, xpc.HeartbeatRequestFlag)
if err != nil {
return AppLaunch{}, fmt.Errorf("LaunchApp: failed to send launch-app request: %w", err)
}
m, err := c.conn.ReceiveOnServerClientStream()
if err != nil {
return AppLaunch{}, err
return AppLaunch{}, fmt.Errorf("launchApp2: %w", err)
}
pid, err := pidFromResponse(m)
if err != nil {
return AppLaunch{}, err
return AppLaunch{}, fmt.Errorf("launchApp3: %w", err)
}
return AppLaunch{Pid: pid}, nil
return AppLaunch{Pid: int(pid)}, nil
}

// Close closes the connection to the appservice
func (c *Connection) Close() error {
return c.conn.Close()
}

// ListProcesses returns a list of processes with their PID and executable path running on the device for iOS17+.
func (c *Connection) ListProcesses() ([]Process, error) {
req := buildListProcessesPayload(c.deviceId)
err := c.conn.Send(req, xpc.HeartbeatRequestFlag)
if err != nil {
return nil, err
return nil, fmt.Errorf("listProcesses send: %w", err)
}
res, err := c.conn.ReceiveOnServerClientStream()
if err != nil {
return nil, err
}

if output, ok := res["CoreDevice.output"].(map[string]interface{}); ok {
if tokens, ok := output["processTokens"].([]interface{}); ok {
processes := make([]Process, len(tokens), len(tokens))
for i, t := range tokens {
var p Process

if processMap, ok := t.(map[string]interface{}); ok {
if pid, ok := processMap["processIdentifier"].(int64); ok {
p.Pid = int(pid)
} else {
return nil, fmt.Errorf("could not parse pid (type: %T)", processMap["processIdentifier"])
}
if processPath, ok := processMap["executableURL"].(map[string]interface{})["relative"].(string); ok {
p.Path = processPath
} else {
return nil, fmt.Errorf("could not parse process path (type: %T)", processMap["executableURL"])
}
} else {
return nil, errors.New("could not get process info")
}

processes[i] = p
}
return processes, nil
return nil, fmt.Errorf("listProcesses receive: %w", err)
}

output, ok := res["CoreDevice.output"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("listProcesses output")
}
tokens, ok := output["processTokens"].([]interface{})
if !ok {
return nil, fmt.Errorf("listProcesses processTokens")
}
processes := make([]Process, len(tokens))
tokensTyped, err := ios.GenericSliceToType[map[string]interface{}](tokens)
if err != nil {
return nil, fmt.Errorf("listProcesses: %w", err)
}
for i, processMap := range tokensTyped {
var p Process
pid, ok := processMap["processIdentifier"].(int64)
if !ok {
return nil, fmt.Errorf("listProcesses processIdentifier")
}
processPathMap, ok := processMap["executableURL"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("listProcesses executableURL")
}
processPath, ok := processPathMap["relative"].(string)
if !ok {
return nil, fmt.Errorf("listProcesses relative")
}

p.Pid = int(pid)
p.Path = processPath

processes[i] = p
}

return nil, fmt.Errorf("could not parse response")
return processes, nil
}

func (c *Connection) KillProcess(pid uint64) error {
// KillProcess kills the process with the given PID for iOS17+.
func (c *Connection) KillProcess(pid int) error {
req := buildSendSignalPayload(c.deviceId, pid, syscall.SIGKILL)
err := c.conn.Send(req, xpc.HeartbeatRequestFlag)
if err != nil {
return err
return fmt.Errorf("killProcess send: %w", err)
}
m, err := c.conn.ReceiveOnServerClientStream()
if err != nil {
return err
return fmt.Errorf("killProcess receive: %w", err)
}
err = getError(m)
if err != nil {
return err
return fmt.Errorf("killProcess: %w", err)
}
return nil
}

// Reboot performs a full reboot of the device
// Reboot performs a full reboot of the device for iOS17+.
// Just calls RebootWithStyle with RebootFull.
func (c *Connection) Reboot() error {
return c.RebootWithStyle(RebootFull)
}

// RebootWithStyle performs a reboot of the device with the given style for iOS17+. For style use RebootFull or RebootUserSpace.
func (c *Connection) RebootWithStyle(style string) error {
err := c.conn.Send(buildRebootPayload(c.deviceId, style))
if err != nil {
return err
return fmt.Errorf("reboot send: %w", err)
}
m, err := c.conn.ReceiveOnServerClientStream()
if err != nil {
Expand All @@ -139,9 +166,19 @@ func (c *Connection) RebootWithStyle(style string) error {
if errors.As(err, &opErr) && opErr.Timeout() {
return nil
}
return err
return fmt.Errorf("reboot receive: %w", err)
}
err = getError(m)
if err != nil {
return fmt.Errorf("reboot: %w", err)
}
return getError(m)
return nil
}

// ExecutableName returns the executable name for a process by removing the path.
func (p Process) ExecutableName() string {
_, file := path.Split(p.Path)
return file
}

func buildAppLaunchPayload(deviceId string, bundleId string, args []interface{}, env map[string]interface{}, options map[string]interface{}, terminateExisting bool) map[string]interface{} {
Expand Down Expand Up @@ -186,7 +223,7 @@ func buildRebootPayload(deviceId string, style string) map[string]interface{} {
})
}

func buildSendSignalPayload(deviceId string, pid uint64, signal syscall.Signal) map[string]interface{} {
func buildSendSignalPayload(deviceId string, pid int, signal syscall.Signal) map[string]interface{} {
return coredevice.BuildRequest(deviceId, "com.apple.coredevice.feature.sendsignaltoprocess", map[string]interface{}{
"process": map[string]interface{}{
"processIdentifier": int64(pid),
Expand All @@ -196,14 +233,19 @@ func buildSendSignalPayload(deviceId string, pid uint64, signal syscall.Signal)
}

func pidFromResponse(response map[string]interface{}) (int64, error) {
if output, ok := response["CoreDevice.output"].(map[string]interface{}); ok {
if processToken, ok := output["processToken"].(map[string]interface{}); ok {
if pid, ok := processToken["processIdentifier"].(int64); ok {
return pid, nil
}
}
output, ok := response["CoreDevice.output"].(map[string]interface{})
if !ok {
return 0, fmt.Errorf("pidFromResponse: could not get pid from response")
}
return 0, fmt.Errorf("could not get pid from response")
processToken, ok := output["processToken"].(map[string]interface{})
if !ok {
return 0, fmt.Errorf("pidFromResponse: could not get processToken from response")
}
pid, ok := processToken["processIdentifier"].(int64)
if !ok {
return 0, fmt.Errorf("pidFromResponse: could not get pid from processToken")
}
return pid, nil
}

func getError(response map[string]interface{}) error {
Expand All @@ -212,8 +254,3 @@ func getError(response map[string]interface{}) error {
}
return nil
}

func (p Process) ExecutableName() string {
_, file := path.Split(p.Path)
return file
}
9 changes: 4 additions & 5 deletions ios/appservice/appservice_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package appservice_test
import (
"github.com/danielpaulus/go-ios/ios"
"github.com/danielpaulus/go-ios/ios/appservice"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"os"
Expand Down Expand Up @@ -50,7 +49,7 @@ func testLaunchAndKillApp(t *testing.T, device ios.DeviceEntry) {
require.NoError(t, err)
defer as.Close()

_, err = as.LaunchApp(uuid.New().String(), "com.apple.mobilesafari", nil, nil, nil)
_, err = as.LaunchApp("com.apple.mobilesafari", nil, nil, nil, true)
require.NoError(t, err)

processes, err := as.ListProcesses()
Expand All @@ -72,12 +71,12 @@ func testKillInvalidPidReturnsError(t *testing.T, device ios.DeviceEntry) {
require.NoError(t, err)
defer as.Close()

launched, err := as.LaunchApp(uuid.New().String(), "com.apple.mobilesafari", nil, nil, nil)
launched, err := as.LaunchApp("com.apple.mobilesafari", nil, nil, nil, true)
require.NoError(t, err)

err = as.KillProcess(int(launched.Pid))
err = as.KillProcess(launched.Pid)
require.NoError(t, err)

err = as.KillProcess(int(launched.Pid))
err = as.KillProcess(launched.Pid)
assert.Error(t, err)
}
12 changes: 6 additions & 6 deletions ios/testmanagerd/xcuitestrunner.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,13 +352,13 @@ func runXUITestWithBundleIdsXcode15Ctx(
return TestSuite{}, fmt.Errorf("runXUITestWithBundleIdsXcode15Ctx: cannot initiate a control session with capabilities: %w", err)
}
log.WithField("caps", caps).Info("got capabilities")
authorized, err := ideDaemonProxy2.daemonConnection.authorizeTestSessionWithProcessID(pid)
authorized, err := ideDaemonProxy2.daemonConnection.authorizeTestSessionWithProcessID(uint64(pid))
if err != nil {
return TestSuite{}, fmt.Errorf("runXUITestWithBundleIdsXcode15Ctx: cannot authorize test session: %w", err)
}
log.WithField("authorized", authorized).Info("authorized")

err = ideDaemonProxy2.daemonConnection.initiateControlSession(pid, proto)
err = ideDaemonProxy2.daemonConnection.initiateControlSession(uint64(pid), proto)
if err != nil {
return TestSuite{}, fmt.Errorf("runXUITestWithBundleIdsXcode15Ctx: cannot initiate a control session: %w", err)
}
Expand Down Expand Up @@ -390,10 +390,10 @@ func runXUITestWithBundleIdsXcode15Ctx(
}

type processKiller interface {
KillProcess(pid uint64) error
KillProcess(pid int) error
}

func killTestRunner(killer processKiller, pid uint64) error {
func killTestRunner(killer processKiller, pid int) error {
log.Infof("Killing test runner with pid %d ...", pid)
err := killer.KillProcess(pid)
if err != nil {
Expand All @@ -406,7 +406,7 @@ func killTestRunner(killer processKiller, pid uint64) error {

func startTestRunner17(device ios.DeviceEntry, appserviceConn *appservice.Connection, xctestConfigPath string, bundleID string,
sessionIdentifier string, testBundlePath string, testArgs []string, testEnv []string,
) (uint64, error) {
) (int, error) {
args := []interface{}{}
for _, arg := range testArgs {
args = append(args, arg)
Expand Down Expand Up @@ -454,7 +454,7 @@ func startTestRunner17(device ios.DeviceEntry, appserviceConn *appservice.Connec
return 0, err
}

return uint64(appLaunch.Pid), nil
return appLaunch.Pid, nil
}

func setupXcuiTest(device ios.DeviceEntry, bundleID string, testRunnerBundleID string, xctestConfigFileName string) (uuid.UUID, string, nskeyedarchiver.XCTestConfiguration, testInfo, error) {
Expand Down
15 changes: 15 additions & 0 deletions ios/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,18 @@ func InterfaceToStringSlice(intfSlice interface{}) []string {
}
return result
}

// GenericSliceToType tries to convert a slice of interfaces to a slice of the given type.
// It returns an error if the conversion fails but will not panic.
// Example: var b []bool; b, err = GenericSliceToType[bool]([]interface{}{true, false})
func GenericSliceToType[T any](input []interface{}) ([]T, error) {
result := make([]T, len(input))
for i, intf := range input {
if t, ok := intf.(T); ok {
result[i] = t
} else {
return []T{}, fmt.Errorf("GenericSliceToType: could not convert %v to %T", intf, result[i])
}
}
return result, nil
}
9 changes: 9 additions & 0 deletions ios/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ type SampleData struct {
FloatValue float64
}

func TestGenericSliceToType(t *testing.T) {
slice := []interface{}{5, 3, 2}
v, err := ios.GenericSliceToType[int](slice)
assert.Nil(t, err)
assert.Equal(t, 3, v[1])
_, err = ios.GenericSliceToType[string](slice)
assert.NotNil(t, err)
}

func TestNtohs(t *testing.T) {
assert.Equal(t, uint16(62078), ios.Ntohs(ios.Lockdownport))
}
Expand Down

0 comments on commit e2ea862

Please sign in to comment.