package uixt import ( "bytes" "encoding/base64" builtinJSON "encoding/json" "fmt" "io" "mime" "mime/multipart" "net" "net/http" "net/url" "os" "regexp" "strings" "time" giDevice "github.com/electricbubble/gidevice" "github.com/pkg/errors" "github.com/rs/zerolog/log" "github.com/httprunner/httprunner/v4/hrp/internal/env" "github.com/httprunner/httprunner/v4/hrp/internal/json" ) const ( // Changes the value of maximum depth for traversing elements source tree. // It may help to prevent out of memory or timeout errors while getting the elements source tree, // but it might restrict the depth of source tree. // A part of elements source tree might be lost if the value was too small. Defaults to 50 snapshotMaxDepth = 10 // Allows to customize accept/dismiss alert button selector. // It helps you to handle an arbitrary element as accept button in accept alert command. // The selector should be a valid class chain expression, where the search root is the alert element itself. // The default button location algorithm is used if the provided selector is wrong or does not match any element. // e.g. **/XCUIElementTypeButton[`label CONTAINS[c] ‘accept’`] acceptAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'允许','好','仅在使用应用期间','稍后再说'}`]" dismissAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'不允许','暂不'}`]" ) const ( defaultWDAPort = 8100 defaultMjpegPort = 9100 ) func InitWDAClient(device *IOSDevice) (*DriverExt, error) { // init wda device iosDevice, err := NewIOSDevice(device.opitons()...) if err != nil { return nil, err } // init WDA driver capabilities := NewCapabilities() capabilities.WithDefaultAlertAction(AlertActionAccept) var driver WebDriver if env.WDA_USB_DRIVER == "" { // default use http driver driver, err = iosDevice.NewHTTPDriver(capabilities) } else { driver, err = iosDevice.NewUSBDriver(capabilities) } if err != nil { return nil, errors.Wrap(err, "failed to init WDA driver") } // switch to iOS springboard before init WDA session // avoid getting stuck when some super app is activate such as douyin or wexin log.Info().Msg("go back to home screen") if err = driver.Homescreen(); err != nil { return nil, errors.Wrap(err, "failed to go back to home screen") } driverExt, err := Extend(driver) if err != nil { return nil, errors.Wrap(err, "failed to extend WebDriver") } settings, err := driverExt.Driver.SetAppiumSettings(map[string]interface{}{ "snapshotMaxDepth": snapshotMaxDepth, "acceptAlertButtonSelector": 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 device.LogOn { err = driverExt.Driver.StartCaptureLog("hrp_wda_log") if err != nil { return nil, err } } if device.PerfOptions != nil { data, err := iosDevice.d.PerfStart(device.perfOpitons()...) if err != nil { return nil, err } driverExt.perfStop = make(chan struct{}) // start performance monitor go func() { for { select { case <-driverExt.perfStop: iosDevice.d.PerfStop() return case d := <-data: fmt.Println(string(d)) driverExt.perfData = append(driverExt.perfData, string(d)) } } }() } driverExt.UUID = iosDevice.UUID() return driverExt, nil } type IOSDeviceOption func(*IOSDevice) func WithUDID(udid string) IOSDeviceOption { return func(device *IOSDevice) { device.UDID = udid } } func WithWDAPort(port int) IOSDeviceOption { return func(device *IOSDevice) { device.Port = port } } func WithWDAMjpegPort(port int) IOSDeviceOption { return func(device *IOSDevice) { device.MjpegPort = port } } func WithLogOn(logOn bool) IOSDeviceOption { return func(device *IOSDevice) { device.LogOn = logOn } } func WithPerfOptions(options ...giDevice.PerfOption) IOSDeviceOption { return func(device *IOSDevice) { device.PerfOptions = &giDevice.PerfOptions{} for _, option := range options { option(device.PerfOptions) } } } func IOSDevices(udid ...string) (devices []giDevice.Device, err error) { var usbmux giDevice.Usbmux if usbmux, err = giDevice.NewUsbmux(); err != nil { return nil, fmt.Errorf("init usbmux failed: %v", err) } if devices, err = usbmux.Devices(); err != nil { return nil, fmt.Errorf("list ios devices failed: %v", err) } // filter by udid var deviceList []giDevice.Device for _, d := range devices { for _, u := range udid { if u != "" && u != d.Properties().SerialNumber { continue } deviceList = append(deviceList, d) } } return deviceList, nil } func NewIOSDevice(options ...IOSDeviceOption) (device *IOSDevice, err error) { device = &IOSDevice{ Port: defaultWDAPort, MjpegPort: defaultMjpegPort, } for _, option := range options { option(device) } deviceList, err := IOSDevices(device.UDID) if err != nil { return nil, err } if len(deviceList) > 0 { device.UDID = deviceList[0].Properties().SerialNumber log.Info().Str("udid", device.UDID).Msg("select device") device.d = deviceList[0] return device, nil } return nil, fmt.Errorf("device %s not found", device.UDID) } type IOSDevice struct { d giDevice.Device PerfOptions *giDevice.PerfOptions `json:"perf_options,omitempty" yaml:"perf_options,omitempty"` UDID string `json:"udid,omitempty" yaml:"udid,omitempty"` Port int `json:"port,omitempty" yaml:"port,omitempty"` // WDA remote port MjpegPort int `json:"mjpeg_port,omitempty" yaml:"mjpeg_port,omitempty"` // WDA remote MJPEG port LogOn bool `json:"log_on,omitempty" yaml:"log_on,omitempty"` } func (dev *IOSDevice) UUID() string { return dev.UDID } func (dev *IOSDevice) forward(localPort, remotePort int) error { log.Info().Int("localPort", localPort).Int("remotePort", remotePort). Str("udid", dev.UDID).Msg("forward tcp port") listener, err := net.Listen("tcp", fmt.Sprintf(":%d", localPort)) if err != nil { log.Error().Err(err).Msg("listen tcp error") return err } go func(listener net.Listener, device giDevice.Device) { for { accept, err := listener.Accept() if err != nil { log.Error().Err(err).Msg("accept error") continue } rInnerConn, err := device.NewConnect(remotePort) if err != nil { log.Error().Err(err).Msg("connect to device failed") os.Exit(1) } rConn := rInnerConn.RawConn() _ = rConn.SetDeadline(time.Time{}) go func(lConn net.Conn) { go func(lConn, rConn net.Conn) { if _, err := io.Copy(lConn, rConn); err != nil { log.Error().Err(err).Msg("copy local -> remote") } }(lConn, rConn) go func(lConn, rConn net.Conn) { if _, err := io.Copy(rConn, lConn); err != nil { log.Error().Err(err).Msg("copy local <- remote") } }(lConn, rConn) }(accept) } }(listener, dev.d) return nil } func (dev *IOSDevice) opitons() (deviceOptions []IOSDeviceOption) { if dev.UDID != "" { deviceOptions = append(deviceOptions, WithUDID(dev.UDID)) } if dev.Port != 0 { deviceOptions = append(deviceOptions, WithWDAPort(dev.Port)) } if dev.MjpegPort != 0 { deviceOptions = append(deviceOptions, WithWDAMjpegPort(dev.MjpegPort)) } return } func (dev *IOSDevice) perfOpitons() (perfOptions []giDevice.PerfOption) { if dev.PerfOptions == nil { return } // system if dev.PerfOptions.SysCPU { perfOptions = append(perfOptions, giDevice.WithPerfSystemCPU(true)) } if dev.PerfOptions.SysMem { perfOptions = append(perfOptions, giDevice.WithPerfSystemMem(true)) } if dev.PerfOptions.SysDisk { perfOptions = append(perfOptions, giDevice.WithPerfSystemDisk(true)) } if dev.PerfOptions.SysNetwork { perfOptions = append(perfOptions, giDevice.WithPerfSystemNetwork(true)) } if dev.PerfOptions.FPS { perfOptions = append(perfOptions, giDevice.WithPerfFPS(true)) } if dev.PerfOptions.Network { perfOptions = append(perfOptions, giDevice.WithPerfNetwork(true)) } // process if dev.PerfOptions.BundleID != "" { perfOptions = append(perfOptions, giDevice.WithPerfBundleID(dev.PerfOptions.BundleID)) } if dev.PerfOptions.Pid != 0 { perfOptions = append(perfOptions, giDevice.WithPerfPID(dev.PerfOptions.Pid)) } // config if dev.PerfOptions.OutputInterval != 0 { perfOptions = append(perfOptions, giDevice.WithPerfOutputInterval(dev.PerfOptions.OutputInterval)) } if dev.PerfOptions.SystemAttributes != nil { perfOptions = append(perfOptions, giDevice.WithPerfSystemAttributes(dev.PerfOptions.SystemAttributes...)) } if dev.PerfOptions.ProcessAttributes != nil { perfOptions = append(perfOptions, giDevice.WithPerfProcessAttributes(dev.PerfOptions.ProcessAttributes...)) } return } // NewHTTPDriver creates new remote HTTP client, this will also start a new session. func (dev *IOSDevice) NewHTTPDriver(capabilities Capabilities) (driver WebDriver, err error) { localPort, err := getFreePort() if err != nil { return nil, errors.Wrap(err, "get free port failed") } if err = dev.forward(localPort, dev.Port); err != nil { return nil, errors.Wrap(err, "forward tcp port failed") } localMjpegPort, err := getFreePort() if err != nil { return nil, errors.Wrap(err, "get free port failed") } if err = dev.forward(localMjpegPort, dev.MjpegPort); err != nil { return nil, errors.Wrap(err, "forward tcp port failed") } log.Info().Interface("capabilities", capabilities). Int("localPort", localPort).Int("localMjpegPort", localMjpegPort). Msg("init WDA HTTP driver") wd := new(wdaDriver) wd.client = http.DefaultClient host := "127.0.0.1" if wd.urlPrefix, err = url.Parse(fmt.Sprintf("http://%s:%d", host, localPort)); err != nil { return nil, err } var sessionInfo SessionInfo if sessionInfo, err = wd.NewSession(capabilities); err != nil { return nil, err } wd.sessionId = sessionInfo.SessionId if wd.mjpegHTTPConn, err = net.Dial( "tcp", fmt.Sprintf("%s:%d", host, localMjpegPort), ); err != nil { return nil, err } wd.mjpegClient = convertToHTTPClient(wd.mjpegHTTPConn) return wd, nil } // NewUSBDriver creates new client via USB connected device, this will also start a new session. func (dev *IOSDevice) NewUSBDriver(capabilities Capabilities) (driver WebDriver, err error) { log.Info().Interface("capabilities", capabilities). Str("udid", dev.UDID).Msg("init WDA USB driver") wd := new(wdaDriver) if wd.defaultConn, err = dev.d.NewConnect(dev.Port, 0); err != nil { return nil, fmt.Errorf("connect port %d failed: %w", dev.Port, err) } wd.client = convertToHTTPClient(wd.defaultConn.RawConn()) if wd.mjpegUSBConn, err = dev.d.NewConnect(dev.MjpegPort, 0); err != nil { return nil, fmt.Errorf("connect MJPEG port %d failed: %w", dev.MjpegPort, err) } wd.mjpegClient = convertToHTTPClient(wd.mjpegUSBConn.RawConn()) if wd.urlPrefix, err = url.Parse("http://" + dev.UDID); err != nil { return nil, err } _, err = wd.NewSession(capabilities) return wd, err } func (dExt *DriverExt) ConnectMjpegStream(httpClient *http.Client) (err error) { if httpClient == nil { return errors.New(`'httpClient' can't be nil`) } var req *http.Request if req, err = http.NewRequest(http.MethodGet, "http://*", nil); err != nil { return err } var resp *http.Response if resp, err = httpClient.Do(req); err != nil { return err } // defer func() { _ = resp.Body.Close() }() var boundary string if _, param, err := mime.ParseMediaType(resp.Header.Get("Content-Type")); err != nil { return err } else { boundary = strings.Trim(param["boundary"], "-") } mjpegReader := multipart.NewReader(resp.Body, boundary) go func() { for { select { case <-dExt.doneMjpegStream: _ = resp.Body.Close() return default: var part *multipart.Part if part, err = mjpegReader.NextPart(); err != nil { dExt.frame = nil continue } raw := new(bytes.Buffer) if _, err = raw.ReadFrom(part); err != nil { dExt.frame = nil continue } dExt.frame = raw } } }() return } func (dExt *DriverExt) CloseMjpegStream() { dExt.doneMjpegStream <- true } type rawResponse []byte func (r rawResponse) checkErr() (err error) { reply := new(struct { Value struct { Err string `json:"error"` Message string `json:"message"` Traceback string `json:"traceback"` // wda Stacktrace string `json:"stacktrace"` // uia } }) if err = json.Unmarshal(r, reply); err != nil { return err } if reply.Value.Err != "" { errText := reply.Value.Message re := regexp.MustCompile(`{.+?=(.+?)}`) if re.MatchString(reply.Value.Message) { subMatch := re.FindStringSubmatch(reply.Value.Message) errText = subMatch[len(subMatch)-1] } return fmt.Errorf("%s: %s", reply.Value.Err, errText) } return } func (r rawResponse) valueConvertToString() (s string, err error) { reply := new(struct{ Value string }) if err = json.Unmarshal(r, reply); err != nil { return "", errors.Wrapf(err, "json.Unmarshal failed, rawResponse: %s", string(r)) } s = reply.Value return } func (r rawResponse) valueConvertToBool() (b bool, err error) { reply := new(struct{ Value bool }) if err = json.Unmarshal(r, reply); err != nil { return false, err } b = reply.Value return } func (r rawResponse) valueConvertToSessionInfo() (sessionInfo SessionInfo, err error) { reply := new(struct{ Value struct{ SessionInfo } }) if err = json.Unmarshal(r, reply); err != nil { return SessionInfo{}, err } sessionInfo = reply.Value.SessionInfo return } func (r rawResponse) valueConvertToJsonRawMessage() (raw builtinJSON.RawMessage, err error) { reply := new(struct{ Value builtinJSON.RawMessage }) if err = json.Unmarshal(r, reply); err != nil { return nil, err } raw = reply.Value return } func (r rawResponse) valueDecodeAsBase64() (raw *bytes.Buffer, err error) { str, err := r.valueConvertToString() if err != nil { return nil, errors.Wrap(err, "failed to convert value to string") } decodeString, err := base64.StdEncoding.DecodeString(str) if err != nil { return nil, errors.Wrap(err, "failed to decode base64 string") } raw = bytes.NewBuffer(decodeString) return } var errNoSuchElement = errors.New("no such element") func (r rawResponse) valueConvertToElementID() (id string, err error) { reply := new(struct{ Value map[string]string }) if err = json.Unmarshal(r, reply); err != nil { return "", err } if len(reply.Value) == 0 { return "", errNoSuchElement } if id = elementIDFromValue(reply.Value); id == "" { return "", fmt.Errorf("invalid element returned: %+v", reply) } return } func (r rawResponse) valueConvertToElementIDs() (IDs []string, err error) { reply := new(struct{ Value []map[string]string }) if err = json.Unmarshal(r, reply); err != nil { return nil, err } if len(reply.Value) == 0 { return nil, errNoSuchElement } IDs = make([]string, len(reply.Value)) for i, elem := range reply.Value { var id string if id = elementIDFromValue(elem); id == "" { return nil, fmt.Errorf("invalid element returned: %+v", reply) } IDs[i] = id } return }