package uixt import ( "context" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "os" "strconv" "time" "github.com/Masterminds/semver" "github.com/danielpaulus/go-ios/ios" "github.com/danielpaulus/go-ios/ios/deviceinfo" "github.com/danielpaulus/go-ios/ios/diagnostics" "github.com/danielpaulus/go-ios/ios/forward" "github.com/danielpaulus/go-ios/ios/imagemounter" "github.com/danielpaulus/go-ios/ios/installationproxy" "github.com/danielpaulus/go-ios/ios/instruments" "github.com/danielpaulus/go-ios/ios/testmanagerd" "github.com/danielpaulus/go-ios/ios/tunnel" "github.com/danielpaulus/go-ios/ios/zipconduit" "github.com/pkg/errors" "github.com/rs/zerolog/log" "github.com/httprunner/httprunner/v5/code" "github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/pkg/uixt/option" ) var tunnelManager *tunnel.TunnelManager = nil func GetIOSDevices(udid ...string) (deviceList []ios.DeviceEntry, err error) { devices, err := ios.ListDevices() if err != nil { return nil, errors.Wrap(code.DeviceConnectionError, fmt.Sprintf("list ios devices failed: %v", err)) } for _, d := range devices.DeviceList { if len(udid) > 0 { for _, u := range udid { if u != "" && u != d.Properties.SerialNumber { continue } // filter non-usb ios devices if d.Properties.ConnectionType != "USB" { continue } deviceList = append(deviceList, d) } } else { deviceList = devices.DeviceList } } if len(deviceList) == 0 { var err error if udid == nil || (len(udid) == 1 && udid[0] == "") { err = fmt.Errorf("no ios device found") } else { err = fmt.Errorf("no ios device found for udid %v", udid) } return nil, err } return deviceList, nil } func StartTunnel(recordsPath string, tunnelInfoPort int, userspaceTUN bool) (err error) { pm, err := tunnel.NewPairRecordManager(recordsPath) if err != nil { return err } tm := tunnel.NewTunnelManager(pm, userspaceTUN) go func() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { err := tm.UpdateTunnels(context.Background()) if err != nil { log.Error().Err(err).Msg("failed to update tunnels") } } }() go func() { err := tunnel.ServeTunnelInfo(tm, tunnelInfoPort) if err != nil { log.Error().Err(err).Msg("failed to start tunnel server") } }() log.Info().Msg("Tunnel server started") return nil } func RebootTunnel() (err error) { if tunnelManager != nil { _ = tunnelManager.Close() } return StartTunnel(os.TempDir(), ios.HttpApiPort(), true) } func NewIOSDevice(opts ...option.IOSDeviceOption) (device *IOSDevice, err error) { deviceOptions := option.NewIOSDeviceOptions(opts...) deviceList, err := GetIOSDevices(deviceOptions.UDID) if err != nil { return nil, errors.Wrap(code.DeviceConnectionError, err.Error()) } if deviceOptions.UDID == "" && len(deviceList) > 1 { return nil, errors.Wrap(code.DeviceConnectionError, "more than one device connected, please specify the udid") } dev := deviceList[0] udid := dev.Properties.SerialNumber if deviceOptions.UDID == "" { deviceOptions.UDID = udid log.Warn(). Str("udid", udid). Msg("ios UDID is not specified, select the first one") } device = &IOSDevice{ IOSDeviceOptions: deviceOptions, listeners: make(map[int]*forward.ConnListener), d: dev, } log.Info().Str("udid", device.UDID).Msg("init ios device") err = device.Setup() if err != nil { _ = device.Teardown() return nil, err } return device, nil } type IOSDevice struct { *option.IOSDeviceOptions d ios.DeviceEntry listeners map[int]*forward.ConnListener } type DeviceDetail struct { DeviceName string `json:"deviceName,omitempty"` DeviceClass string `json:"deviceClass,omitempty"` ProductVersion string `json:"productVersion,omitempty"` ProductType string `json:"productType,omitempty"` ProductName string `json:"productName,omitempty"` PasswordProtected bool `json:"passwordProtected,omitempty"` ModelNumber string `json:"modelNumber,omitempty"` SerialNumber string `json:"serialNumber,omitempty"` SIMStatus string `json:"simStatus,omitempty"` PhoneNumber string `json:"phoneNumber,omitempty"` CPUArchitecture string `json:"cpuArchitecture,omitempty"` ProtocolVersion string `json:"protocolVersion,omitempty"` RegionInfo string `json:"regionInfo,omitempty"` TimeZone string `json:"timeZone,omitempty"` UniqueDeviceID string `json:"uniqueDeviceID,omitempty"` WiFiAddress string `json:"wifiAddress,omitempty"` BuildVersion string `json:"buildVersion,omitempty"` } type ApplicationType string const ( ApplicationTypeSystem ApplicationType = "System" ApplicationTypeUser ApplicationType = "User" ApplicationTypeInternal ApplicationType = "internal" ApplicationTypeAny ApplicationType = "Any" ) func (dev *IOSDevice) Setup() error { images, err := dev.ListImages() if err != nil { return err } version, err := dev.getVersion() if err != nil { return err } if len(images) == 0 && version.LessThan(ios.IOS17()) { // Notice: iOS 17.0+ does not need to mount developer image err = dev.AutoMountImage(os.TempDir()) if err != nil { return err } } return nil } func (dev *IOSDevice) Teardown() error { for _, listener := range dev.listeners { _ = listener.Close() } return nil } func (dev *IOSDevice) UUID() string { return dev.UDID } func (dev *IOSDevice) LogEnabled() bool { return dev.LogOn } func (dev *IOSDevice) getAppInfo(packageName string) (appInfo AppInfo, err error) { apps, err := dev.ListApps(ApplicationTypeAny) if err != nil { return AppInfo{}, err } for _, app := range apps { if app.CFBundleIdentifier == packageName { appInfo.BundleId = app.CFBundleIdentifier appInfo.AppName = app.CFBundleName appInfo.PackageName = app.CFBundleIdentifier appInfo.VersionName = app.CFBundleShortVersionString appInfo.VersionCode = app.CFBundleVersion return appInfo, err } } return AppInfo{}, fmt.Errorf("not found App by bundle id: %s", packageName) } func (dev *IOSDevice) NewDriver(opts ...option.DriverOption) (driverExt *DriverExt, err error) { options := option.NewDriverOptions() // init WDA driver capabilities := options.Capabilities if capabilities == nil { capabilities = option.NewCapabilities() capabilities.WithDefaultAlertAction(option.AlertActionAccept) } var driver IDriver if dev.STUB { driver, err = dev.NewStubDriver() if err != nil { return nil, errors.Wrap(err, "failed to init Stub driver") } } else { driver, err = dev.NewHTTPDriver(capabilities) if err != nil { return nil, errors.Wrap(err, "failed to init WDA driver") } settings, err := driver.SetAppiumSettings(map[string]interface{}{ "snapshotMaxDepth": dev.SnapshotMaxDepth, "acceptAlertButtonSelector": dev.AcceptAlertButtonSelector, }) if err != nil { return nil, errors.Wrap(err, "failed to set appium WDA settings") } log.Info().Interface("appiumWDASettings", settings).Msg("set appium WDA settings") } if dev.ResetHomeOnStartup { log.Info().Msg("go back to home screen") if err = driver.Homescreen(); err != nil { return nil, errors.Wrap(code.MobileUIDriverError, fmt.Sprintf("go back to home screen failed: %v", err)) } } driverExt, err = newDriverExt(dev, driver, opts...) if err != nil { return nil, err } settings, err := driverExt.Driver.SetAppiumSettings(map[string]interface{}{ "snapshotMaxDepth": dev.SnapshotMaxDepth, "acceptAlertButtonSelector": dev.AcceptAlertButtonSelector, }) if err != nil { return nil, errors.Wrap(err, "failed to set appium WDA settings") } log.Info().Interface("appiumWDASettings", settings).Msg("set appium WDA settings") if dev.LogOn { err = driverExt.Driver.StartCaptureLog("hrp_wda_log") if err != nil { return nil, err } } return driverExt, nil } func (dev *IOSDevice) Install(appPath string, opts ...option.InstallOption) (err error) { installOpts := option.NewInstallOptions(opts...) for i := 0; i <= installOpts.RetryTimes; i++ { var conn *zipconduit.Connection conn, err = zipconduit.New(dev.d) if err != nil { return err } defer conn.Close() err = conn.SendFile(appPath) if err != nil { log.Error().Err(err).Msg(fmt.Sprintf("failed to install app Retry time %d", i)) } if err == nil { return nil } } return err } func (dev *IOSDevice) InstallByUrl(url string, opts ...option.InstallOption) (err error) { appPath, err := builtin.DownloadFileByUrl(url) if err != nil { return err } err = dev.Install(appPath, opts...) if err != nil { return err } return nil } func (dev *IOSDevice) Uninstall(bundleId string) error { svc, err := installationproxy.New(dev.d) if err != nil { return err } defer svc.Close() err = svc.Uninstall(bundleId) if err != nil { return err } return nil } func (dev *IOSDevice) forward(localPort, remotePort int) error { if dev.listeners[localPort] != nil { log.Warn().Msg(fmt.Sprintf("local port :%d is already in use", localPort)) _ = dev.listeners[localPort].Close() } listener, err := forward.Forward(dev.d, uint16(localPort), uint16(remotePort)) if err != nil { log.Error().Err(err).Msg(fmt.Sprintf("failed to forward %d to %d", localPort, remotePort)) return err } dev.listeners[localPort] = listener return nil } func (dev *IOSDevice) GetDeviceInfo() (*DeviceDetail, error) { deviceInfo, err := deviceinfo.NewDeviceInfo(dev.d) if err != nil { log.Error().Err(err).Msg("failed to get device info") return nil, err } defer deviceInfo.Close() info, err := deviceInfo.GetDisplayInfo() if err != nil { log.Error().Err(err).Msg("failed to get device info") return nil, err } jsonData, err := json.Marshal(info) if err != nil { return nil, err } // 将 JSON 反序列化为结构体 detail := new(DeviceDetail) err = json.Unmarshal(jsonData, &detail) if err != nil { return nil, err } return detail, err } func (dev *IOSDevice) ListApps(appType ApplicationType) (apps []installationproxy.AppInfo, err error) { svc, _ := installationproxy.New(dev.d) defer svc.Close() switch appType { case ApplicationTypeSystem: apps, err = svc.BrowseSystemApps() case ApplicationTypeAny: apps, err = svc.BrowseAllApps() case ApplicationTypeInternal: apps, err = svc.BrowseFileSharingApps() case ApplicationTypeUser: apps, err = svc.BrowseUserApps() } if err != nil { log.Error().Err(err).Msg("failed to list ios apps") return nil, err } return apps, nil } func (dev *IOSDevice) GetAppInfo(packageName string) (appInfo installationproxy.AppInfo, err error) { svc, _ := installationproxy.New(dev.d) defer svc.Close() apps, err := svc.BrowseAllApps() if err != nil { log.Error().Err(err).Msg("failed to list ios apps") return installationproxy.AppInfo{}, err } for _, app := range apps { if app.CFBundleIdentifier == packageName { return app, nil } } return installationproxy.AppInfo{}, nil } func (dev *IOSDevice) ListImages() (images []string, err error) { conn, err := imagemounter.NewImageMounter(dev.d) if err != nil { return nil, errors.Wrap(code.DeviceConnectionError, err.Error()) } defer conn.Close() signatures, err := conn.ListImages() if err != nil { return nil, errors.Wrap(code.DeviceConnectionError, err.Error()) } for _, sig := range signatures { images = append(images, fmt.Sprintf("%x", sig)) } return } func (dev *IOSDevice) MountImage(imagePath string) (err error) { log.Info().Str("imagePath", imagePath).Msg("mount ios developer image") conn, err := imagemounter.NewImageMounter(dev.d) if err != nil { return errors.Wrap(code.DeviceConnectionError, err.Error()) } defer conn.Close() err = conn.MountImage(imagePath) if err != nil { return errors.Wrapf(code.DeviceConnectionError, "mount ios developer image failed: %v", err) } log.Info().Str("imagePath", imagePath).Msg("mount ios developer image success") return nil } func (dev *IOSDevice) AutoMountImage(baseDir string) (err error) { log.Info().Str("baseDir", baseDir).Msg("auto mount ios developer image") imagePath, err := imagemounter.DownloadImageFor(dev.d, baseDir) if err != nil { return errors.Wrapf(code.DeviceConnectionError, "download ios developer image failed: %v", err) } return dev.MountImage(imagePath) } func (dev *IOSDevice) RunXCTest(ctx context.Context, bundleID, testRunnerBundleID, xctestConfig string) (err error) { log.Info().Str("bundleID", bundleID). Str("testRunnerBundleID", testRunnerBundleID). Str("xctestConfig", xctestConfig). Msg("run xctest") listener := testmanagerd.NewTestListener(io.Discard, io.Discard, os.TempDir()) config := testmanagerd.TestConfig{ BundleId: bundleID, TestRunnerBundleId: testRunnerBundleID, XctestConfigName: xctestConfig, Device: dev.d, Listener: listener, } _, err = testmanagerd.RunTestWithConfig(ctx, config) if err != nil { log.Error().Err(err).Msg("run xctest failed") return err } return nil } func (dev *IOSDevice) RunXCTestDaemon(ctx context.Context, bundleID, testRunnerBundleID, xctestConfig string) { ctx, stopWda := context.WithCancel(ctx) go func() { err := dev.RunXCTest(ctx, bundleID, testRunnerBundleID, xctestConfig) if err != nil { log.Error().Err(err).Msg("wda ended") } stopWda() }() } func (dev *IOSDevice) getVersion() (version *semver.Version, err error) { version, err = ios.GetProductVersion(dev.d) if err != nil { log.Error().Err(err).Msg("failed to get version") return nil, err } log.Info().Str("version", version.String()).Msg("get ios device version") return version, nil } func (dev *IOSDevice) ListProcess(applicationsOnly bool) (processList []instruments.ProcessInfo, err error) { service, err := instruments.NewDeviceInfoService(dev.d) if err != nil { log.Error().Err(err).Msg("failed to list process") return } defer service.Close() processList, err = service.ProcessList() if applicationsOnly { var applicationProcessList []instruments.ProcessInfo for _, processInfo := range processList { if processInfo.IsApplication { applicationProcessList = append(applicationProcessList, processInfo) } } processList = applicationProcessList } return } func (dev *IOSDevice) Reboot() error { err := diagnostics.Reboot(dev.d) if err != nil { log.Error().Err(err).Msg("failed to reboot device") return err } return nil } // NewHTTPDriver creates new remote HTTP client, this will also start a new session. func (dev *IOSDevice) NewHTTPDriver(capabilities option.Capabilities) (driver IDriver, err error) { var localPort int localPort, err = strconv.Atoi(os.Getenv("WDA_LOCAL_PORT")) if err != nil { localPort, err = builtin.GetFreePort() if err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, fmt.Sprintf("get free port failed: %v", err)) } if err = dev.forward(localPort, dev.WDAPort); err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, fmt.Sprintf("forward tcp port failed: %v", err)) } } else { log.Info().Int("WDA_LOCAL_PORT", localPort).Msg("reuse WDA local port") } var localMjpegPort int localMjpegPort, err = strconv.Atoi(os.Getenv("WDA_LOCAL_MJPEG_PORT")) if err != nil { localMjpegPort, err = builtin.GetFreePort() if err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, fmt.Sprintf("get free port failed: %v", err)) } if err = dev.forward(localMjpegPort, dev.WDAMjpegPort); err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, fmt.Sprintf("forward tcp port failed: %v", err)) } } else { log.Info().Int("WDA_LOCAL_MJPEG_PORT", localMjpegPort). Msg("reuse WDA local mjpeg port") } log.Info().Interface("capabilities", capabilities). Int("localPort", localPort).Int("localMjpegPort", localMjpegPort). Msg("init WDA HTTP driver") wd := new(wdaDriver) wd.IOSDevice = dev wd.udid = dev.UDID wd.Client = &http.Client{ Timeout: time.Second * 10, // 设置超时时间为 10 秒 } host := "localhost" if wd.urlPrefix, err = url.Parse(fmt.Sprintf("http://%s:%d", host, localPort)); err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, err.Error()) } // create new session var sessionInfo SessionInfo if sessionInfo, err = wd.NewSession(capabilities); err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, err.Error()) } wd.session.ID = sessionInfo.SessionId if wd.mjpegHTTPConn, err = net.Dial( "tcp", fmt.Sprintf("%s:%d", host, localMjpegPort), ); err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, err.Error()) } wd.mjpegClient = convertToHTTPClient(wd.mjpegHTTPConn) wd.mjpegUrl = fmt.Sprintf("%s:%d", host, localMjpegPort) // init WDA scale if wd.scale, err = wd.Scale(); err != nil { return nil, err } return wd, nil } const ( defaultBightInsightPort = 8000 defaultDouyinServerPort = 32921 ) func (dev *IOSDevice) NewStubDriver() (driver IDriver, err error) { localStubPort, err := builtin.GetFreePort() if err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, fmt.Sprintf("get free port failed: %v", err)) } if err = dev.forward(localStubPort, defaultBightInsightPort); err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, fmt.Sprintf("forward tcp port failed: %v", err)) } localServerPort, err := builtin.GetFreePort() if err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, fmt.Sprintf("get free port failed: %v", err)) } if err = dev.forward(localServerPort, defaultDouyinServerPort); err != nil { return nil, errors.Wrap(code.DeviceHTTPDriverError, fmt.Sprintf("forward tcp port failed: %v", err)) } host := "localhost" stubDriver, err := newStubIOSDriver( fmt.Sprintf("http://%s:%d", host, localStubPort), fmt.Sprintf("http://%s:%d", host, localServerPort), dev) if err != nil { return nil, err } return stubDriver, nil } func (dev *IOSDevice) GetCurrentWindow() (WindowInfo, error) { return WindowInfo{}, nil } func (dev *IOSDevice) GetPackageInfo(packageName string) (AppInfo, error) { svc, err := installationproxy.New(dev.d) if err != nil { return AppInfo{}, errors.Wrap(code.DeviceGetInfoError, err.Error()) } defer svc.Close() apps, err := svc.BrowseAllApps() if err != nil { return AppInfo{}, errors.Wrap(code.DeviceGetInfoError, err.Error()) } for _, app := range apps { if app.CFBundleIdentifier != packageName { continue } appInfo := AppInfo{ Name: app.CFBundleName, AppBaseInfo: AppBaseInfo{ BundleId: app.CFBundleIdentifier, PackageName: app.CFBundleIdentifier, VersionName: app.CFBundleShortVersionString, VersionCode: app.CFBundleVersion, AppName: app.CFBundleDisplayName, AppPath: app.Path, }, } log.Info().Interface("appInfo", appInfo).Msg("get package info") return appInfo, nil } return AppInfo{}, errors.Wrap(code.DeviceAppNotInstalled, fmt.Sprintf("%s not found", packageName)) }