├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .github ├── semantic.yml └── workflows │ ├── ci.yml │ └── docs-build.yml ├── .gitignore ├── .vscode └── launch.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── .eslintrc.js ├── components │ ├── app-info.jsx │ ├── app-info.less │ ├── index.jsx │ ├── info.jsx │ ├── info.less │ ├── screen.jsx │ ├── screen.less │ ├── tree.jsx │ └── tree.less ├── index.jsx ├── index.less └── libs │ ├── bounds.js │ ├── utils.js │ └── xpath.js ├── bin └── app-inspector.js ├── css ├── cayman.css └── normalize.css ├── docs ├── .vuepress │ ├── config.js │ ├── override.styl │ └── public │ │ └── assets │ │ ├── 6d308bd9gy1fg7cnt9hf6j20t70h7782.jpg │ │ └── 7dfcf2f7gw1f77ev6csw5g20s50iwe81.gif ├── README.md ├── guide │ ├── get-device-id.md │ ├── install.md │ └── quick-start.md └── zh │ ├── README.md │ └── guide │ ├── get-device-id.md │ ├── install.md │ └── quick-start.md ├── index.js ├── issue_template.md ├── lib ├── android.js ├── app-inspector.js ├── common │ ├── helper.js │ └── logger.js ├── config.js ├── ios.js ├── middlewares.js ├── render.js ├── router.js └── server.js ├── package.json ├── public └── 3rdparty │ ├── react-dom.min.js │ └── react.min.js ├── test ├── bin │ ├── app-inspector.test.js │ └── utils.js ├── lib │ └── common │ │ └── helper.test.js └── mocha.opts ├── views └── index.html └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/.* 2 | **/node_modules 3 | **/test 4 | **/logs 5 | **/coverage/ 6 | **/assets/ 7 | **/dist/ 8 | **/docs_dist/ 9 | **/public 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | root: true, 5 | extends: 'eslint-config-egg', 6 | parserOptions: { 7 | ecmaVersion: 2020, 8 | }, 9 | plugins: [], 10 | rules: { 11 | 'valid-jsdoc': 0, 12 | 'no-script-url': 0, 13 | 'no-multi-spaces': 0, 14 | 'default-case': 0, 15 | 'no-case-declarations': 0, 16 | 'one-var-declaration-per-line': 0, 17 | 'no-restricted-syntax': 0, 18 | 'jsdoc/require-param': 0, 19 | 'jsdoc/check-param-names': 0, 20 | 'jsdoc/require-param-description': 0, 21 | 'arrow-parens': 0, 22 | 'prefer-promise-reject-errors': 0, 23 | 'no-control-regex': 0, 24 | 'no-use-before-define': 0, 25 | 'array-callback-return': 0, 26 | 'no-bitwise': 0, 27 | 'no-self-compare': 0, 28 | 'one-var': 0, 29 | 'no-trailing-spaces': [ 'warn', { skipBlankLines: true }], 30 | 'no-return-await': 0, 31 | }, 32 | globals: { 33 | window: true, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | titleOnly: true 2 | types: 3 | - feat 4 | - fix 5 | - docs 6 | - dx 7 | - refactor 8 | - perf 9 | - test 10 | - workflow 11 | - build 12 | - ci 13 | - chore 14 | - types 15 | - wip 16 | - release 17 | - deps 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # allows to manually run the job at any time 5 | workflow_dispatch: 6 | 7 | push: 8 | branches: 9 | - '**' 10 | 11 | jobs: 12 | test: 13 | name: 'Run Unit Test: node-16, macos-latest' 14 | timeout-minutes: 10 15 | runs-on: macos-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set node version to 16 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: '16' 26 | 27 | - name: Install deps 28 | # https://github.com/webpack-contrib/terser-webpack-plugin/issues/66 29 | run: | 30 | npm i terser@3.14.1 31 | npm install 32 | 33 | - name: Run ci 34 | run: | 35 | npm run lint 36 | npm run build 37 | npm run test 38 | 39 | - name: Codecov 40 | uses: codecov/codecov-action@v3.0.0 41 | with: 42 | token: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/docs-build.yml: -------------------------------------------------------------------------------- 1 | name: Docs Build 2 | 3 | on: 4 | # allows to manually run the job at any time 5 | workflow_dispatch: 6 | 7 | # run on every push on the master branch 8 | push: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | docs-build: 14 | timeout-minutes: 10 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Set node version to 16 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: '16' 27 | 28 | - name: Install deps 29 | run: npm install vuepress macaca-ecosystem -D 30 | 31 | - name: Build docs 32 | run: npm run docs:build 33 | 34 | - name: Deploy to GitHub Pages 35 | if: success() 36 | uses: peaceiris/actions-gh-pages@v3 37 | with: 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | publish_dir: ./docs_dist 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.sw* 3 | *.un~ 4 | coverage/ 5 | docs_dist/ 6 | .nyc_output 7 | .temp 8 | public/dist 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch", 6 | "type": "node", 7 | "request": "launch", 8 | "program": "${workspaceRoot}/bin/app-inspector", 9 | "stopOnEntry": false, 10 | "args": [ "-u", "41D38B64-877A-4020-A212-67DA5F248235", "--verbose" ], 11 | "cwd": "${workspaceRoot}", 12 | "preLaunchTask": null, 13 | "runtimeExecutable": null, 14 | "runtimeArgs": [ 15 | "--nolazy" 16 | ], 17 | "env": { 18 | "NODE_ENV": "development", 19 | "APP_INSPECTOR": "dev" 20 | }, 21 | "console": "internalConsole", 22 | "sourceMaps": false, 23 | "outFiles": [] 24 | }, 25 | { 26 | "name": "Attach", 27 | "type": "node", 28 | "request": "attach", 29 | "port": 5858, 30 | "address": "localhost", 31 | "restart": false, 32 | "sourceMaps": false, 33 | "outFiles": [], 34 | "localRoot": "${workspaceRoot}", 35 | "remoteRoot": null 36 | }, 37 | { 38 | "name": "Attach to Process", 39 | "type": "node", 40 | "request": "attach", 41 | "processId": "${command.PickProcess}", 42 | "port": 5858, 43 | "sourceMaps": false, 44 | "outFiles": [] 45 | } 46 | ] 47 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Macaca App Inspector 2 | 3 | We love pull requests from everyone. 4 | 5 | ## Link Global To Local 6 | 7 | ```bash 8 | $ cd path/to/app-inspector 9 | $ npm link 10 | # now project app-inspector is linked to your global 11 | $ app-inspector -v 12 | # start dev 13 | $ npm run dev 14 | ``` 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright (c) 2017 Alibaba Group Holding Limited and other contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # app-inspector 2 | 3 | --- 4 | 5 | [![NPM version][npm-image]][npm-url] 6 | [![Package quality][quality-image]][quality-url] 7 | [![CI][CI-image]][CI-url] 8 | [![Test coverage][coveralls-image]][coveralls-url] 9 | [![node version][node-image]][node-url] 10 | [![npm download][download-image]][download-url] 11 | 12 | [npm-image]: https://img.shields.io/npm/v/app-inspector.svg 13 | [npm-url]: https://npmjs.org/package/app-inspector 14 | [quality-image]: https://packagequality.com/shield/app-inspector.svg 15 | [quality-url]: https://packagequality.com/#?package=app-inspector 16 | [CI-image]: https://github.com/macacajs/app-inspector/actions/workflows/ci.yml/badge.svg 17 | [CI-url]: https://github.com/macacajs/app-inspector/actions/workflows/ci.yml 18 | [coveralls-image]: https://img.shields.io/coveralls/macacajs/app-inspector.svg 19 | [coveralls-url]: https://coveralls.io/r/macacajs/app-inspector?branch=master 20 | [node-image]: https://img.shields.io/badge/node.js-%3E=_8-green.svg 21 | [node-url]: http://nodejs.org/download/ 22 | [download-image]: https://img.shields.io/npm/dm/app-inspector.svg 23 | [download-url]: https://npmjs.org/package/app-inspector 24 | 25 | [App-inspector](//macacajs.github.io/app-inspector/) is a mobile UI viewer in browser. 26 | 27 | 28 | 29 | ## Contributors 30 | 31 | |[
xudafeng](https://github.com/xudafeng)
|[
meowtec](https://github.com/meowtec)
|[
paradite](https://github.com/paradite)
|[
zivyangll](https://github.com/zivyangll)
|[
ziczhu](https://github.com/ziczhu)
|[
CodeToSurvive1](https://github.com/CodeToSurvive1)
| 32 | | :---: | :---: | :---: | :---: | :---: | :---: | 33 | [
qichuan](https://github.com/qichuan)
|[
snapre](https://github.com/snapre)
|[
risinek](https://github.com/risinek)
|[
mahalo777](https://github.com/mahalo777)
|[
zhuyali](https://github.com/zhuyali)
34 | 35 | This project follows the git-contributor [spec](https://github.com/xudafeng/git-contributor), auto updated at `Thu Apr 21 2022 00:04:00 GMT+0800`. 36 | 37 | 38 | 39 | ![](https://macacajs.github.io/app-inspector/assets/7dfcf2f7gw1f77ev6csw5g20s50iwe81.gif) 40 | 41 | ## Installation 42 | 43 | App-inspector is distibuted through npm. To install it, run the following command line: 44 | 45 | ```bash 46 | $ npm i app-inspector -g 47 | ``` 48 | 49 | Note: If you are going to use app-inspector on real iOS device, see [iOS Real Device section](#ios-real-device) 50 | 51 | ## Usage 52 | 53 | ```bash 54 | $ app-inspector -u xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 55 | ``` 56 | 57 | ## Home Page 58 | 59 | Visit https://macacajs.github.io/app-inspector/ for more information. 60 | 61 | ### iOS Real Device 62 | 63 | First, find the Development Team ID as shown on image below. 64 | 65 | ![](https://macacajs.github.io/app-inspector/assets/6d308bd9gy1fg7cnt9hf6j20t70h7782.jpg) 66 | 67 | Run this command where TEAM_ID is your ID from the first step. 68 | 69 | ```bash 70 | $ DEVELOPMENT_TEAM_ID=TEAM_ID npm i app-inspector -g 71 | ``` 72 | 73 | ## License 74 | 75 | The MIT License (MIT) 76 | -------------------------------------------------------------------------------- /assets/.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'eslint-config-airbnb', 5 | parser: '@babel/eslint-parser', 6 | env: { 7 | browser: true, 8 | es6: true, 9 | }, 10 | parserOptions: { 11 | ecmaVersion: 6, 12 | sourceType: 'module', 13 | requireConfigFile: false, 14 | ecmaFeatures: { 15 | jsx: true, 16 | }, 17 | babelOptions: { 18 | presets: ['@babel/preset-react'], 19 | }, 20 | }, 21 | plugins: ['babel', 'react'], 22 | rules: { 23 | camelcase: 0, 24 | 'no-restricted-syntax': 0, 25 | 'no-plusplus': 0, 26 | 'no-underscore-dangle': 0, 27 | 'no-useless-escape': 0, 28 | 'no-prototype-builtins': 0, 29 | 'max-len': 0, 30 | 'class-methods-use-this': 0, 31 | 'function-paren-newline': 0, 32 | 'react/no-array-index-key': 0, 33 | 'react/no-danger': 0, 34 | 'react/jsx-no-undef': [2, { allowGlobals: true }], 35 | 'react/no-find-dom-node': 0, 36 | 'react/no-did-mount-set-state': 0, 37 | 'react/jsx-no-target-blank': 0, 38 | 'react/no-did-update-set-state': 0, 39 | 'react/prop-types': 0, 40 | 'react/jsx-filename-extension': 0, 41 | 'react/sort-comp': 0, 42 | 'react/require-default-props': 0, 43 | 'react/no-typos': 0, 44 | 'react/forbid-prop-types': 0, 45 | 'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], 46 | 'react/jsx-one-expression-per-line': 0, 47 | 'react/destructuring-assignment': 0, 48 | 'react/no-unused-prop-types': ['warn'], 49 | 'react/self-closing-comp': 0, 50 | 'react/no-unescaped-entities': 0, 51 | 'react/jsx-props-no-spreading': 0, 52 | 'react/no-unused-state': 0, 53 | 'react/no-deprecated': 0, 54 | 'react/state-in-constructor': 0, 55 | 'react/no-access-state-in-setstate': 0, 56 | 'react/no-unstable-nested-components': 0, 57 | 'react/no-unused-class-component-methods': 0, 58 | 'react/jsx-no-bind': 0, 59 | 'jsx-a11y/no-static-element-interactions': 0, 60 | 'jsx-a11y/no-noninteractive-element-interactions': 0, 61 | 'jsx-a11y/anchor-has-content': 0, 62 | 'jsx-a11y/click-events-have-key-events': 0, 63 | 'jsx-a11y/anchor-is-valid': 0, 64 | 'jsx-a11y/alt-text': 0, 65 | 'jsx-a11y/mouse-events-have-key-events': 0, 66 | 'jsx-a11y/iframe-has-title': 0, 67 | 'jsx-a11y/media-has-caption': 0, 68 | 'jsx-a11y/tabindex-no-positive': 0, 69 | 'jsx-a11y/aria-role': 0, 70 | 'jsx-a11y/no-noninteractive-tabindex': 0, 71 | 'jsx-a11y/label-has-for': 0, 72 | 'jsx-a11y/accessible-emoji': 0, 73 | 'jsx-a11y/label-has-associated-control': 0, 74 | 'jsx-a11y/control-has-associated-label': 0, 75 | 'no-await-in-loop': 0, 76 | 'comma-dangle': [ 77 | 'error', 78 | { 79 | arrays: 'always-multiline', 80 | objects: 'always-multiline', 81 | imports: 'always-multiline', 82 | exports: 'always-multiline', 83 | functions: 'ignore', 84 | }, 85 | ], 86 | 'no-param-reassign': 0, 87 | 'consistent-return': 0, 88 | 'object-curly-newline': ['error', { consistent: true, minProperties: 6 }], 89 | 'arrow-parens': 0, 90 | 'arrow-body-style': ['error', 'always'], 91 | 'prefer-destructuring': [ 92 | 'error', 93 | { 94 | VariableDeclarator: { 95 | array: false, 96 | object: true, 97 | }, 98 | AssignmentExpression: { 99 | array: false, 100 | object: false, 101 | }, 102 | }, 103 | ], 104 | 'import/named': 0, 105 | 'import/no-extraneous-dependencies': 0, 106 | 'no-mixed-operators': 0, 107 | 'no-shadow': 0, 108 | 'no-useless-constructor': 0, 109 | 'no-return-assign': 0, 110 | 'prefer-rest-params': 0, 111 | 'no-restricted-globals': [0, 'location'], 112 | 'prefer-promise-reject-errors': 0, 113 | 'no-bitwise': 0, 114 | 'no-return-await': 0, 115 | 'import/prefer-default-export': 0, 116 | 'func-names': 0, 117 | 'no-use-before-define': 0, 118 | 'global-require': 0, 119 | 'no-else-return': 0, 120 | 'no-lonely-if': 0, 121 | 'guard-for-in': 0, 122 | 'one-var': 0, 123 | 'one-var-declaration-per-line': 0, 124 | 'no-console': 0, 125 | 'no-unused-expressions': 0, 126 | 'no-unused-vars': 0, 127 | 'no-continue': 0, 128 | 'no-eval': 0, 129 | 'default-param-last': 0, 130 | 'default-case': 0, 131 | 'semi-style': 0, 132 | 'import/no-import-module-exports': 0, 133 | 'import/extensions': 0, 134 | }, 135 | }; 136 | -------------------------------------------------------------------------------- /assets/components/app-info.jsx: -------------------------------------------------------------------------------- 1 | import pkg from '../../package.json'; 2 | import './app-info.less'; 3 | 4 | export default () => ( 5 |
6 | v{ pkg.version } 7 | 8 | © 2015-{ (new Date).getFullYear() } Macaca 9 | 10 | 11 | 12 | Need help? 13 | 14 | 15 |
16 | ); 17 | -------------------------------------------------------------------------------- /assets/components/app-info.less: -------------------------------------------------------------------------------- 1 | .app-info { 2 | color: #666; 3 | font-size: 12px; 4 | border-top: 1px solid #eee; 5 | padding: 5px; 6 | text-align: center; 7 | 8 | a { 9 | text-decoration: none; 10 | color: #666; 11 | 12 | &:hover { 13 | text-decoration: underline; 14 | } 15 | } 16 | 17 | span, strong { 18 | margin: 0 5px; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /assets/components/index.jsx: -------------------------------------------------------------------------------- 1 | import ReactGA from 'react-ga'; 2 | import React, { Component } from 'react'; 3 | import GitHubButton from 'react-github-button'; 4 | 5 | import 'react-github-button/assets/style.css'; 6 | 7 | import Tree from './tree'; 8 | import Info from './info'; 9 | import Screen from './screen'; 10 | import AppInfo from './app-info'; 11 | import { 12 | getXPath, 13 | getXPathLite 14 | } from '../libs/xpath'; 15 | import { getNodePathByXY } from '../libs/bounds'; 16 | 17 | const { appData } = window; 18 | const { isIOS, serverStarted, dumpFailed } = appData; 19 | 20 | window.addEventListener('load', () => { 21 | ReactGA.initialize('UA-49226133-2'); 22 | ReactGA.pageview(window.location.pathname + window.location.search); 23 | process.env.traceFragment; 24 | }); 25 | 26 | class App extends Component { 27 | 28 | constructor() { 29 | super(); 30 | 31 | this.state = { 32 | node: null, 33 | tree: null, 34 | xpath_lite: null, 35 | xpath: null, 36 | focusBounds: null, 37 | treeViewPortWidth: null, 38 | timer: 0, 39 | }; 40 | 41 | window.addEventListener('resize', () => this.resizeTreeViewport()); 42 | } 43 | 44 | componentDidMount() { 45 | if (serverStarted) { 46 | fetch(isIOS ? './ios.json' : './android.json') 47 | .then(res => res.json()) 48 | .then(tree => { 49 | this.setState({ tree }); 50 | }); 51 | } else { 52 | setTimeout(() => location.reload(), 3000); 53 | } 54 | } 55 | 56 | componentWillUnmount() { 57 | clearTimeout(this.timer); 58 | } 59 | 60 | handleTreeSelect(node, nodePath) { 61 | const { tree } = this.state; 62 | 63 | this.setState({ 64 | node, 65 | focusBounds: node.bounds, 66 | xpath_lite: getXPathLite(tree, nodePath), 67 | xpath: getXPath(tree, nodePath) 68 | }); 69 | this.resizeTreeViewport(); 70 | } 71 | 72 | handleMouseEnter(node) { 73 | this.setState({ 74 | focusBounds: node.bounds 75 | }); 76 | } 77 | 78 | handleMouseLeave(node) { 79 | this.setState({ 80 | focusBounds: null 81 | }); 82 | } 83 | 84 | handleCanvasClick(x, y) { 85 | const nodePath = getNodePathByXY(this.state.tree, isIOS, x, y); 86 | if (!nodePath) return; 87 | this.refs.tree.focus(nodePath); 88 | this.resizeTreeViewport(); 89 | this.updateTreeScrollerStyle(); 90 | } 91 | 92 | resizeTreeViewport() { 93 | setTimeout(() => { 94 | this.refs.treeScroller && this.setState({ 95 | treeViewPortWidth: this.refs.treeScroller.scrollWidth 96 | }); 97 | }); 98 | } 99 | 100 | updateTreeScrollerStyle() { 101 | clearTimeout(this.timer); 102 | this.timer = setTimeout(() => { 103 | const scrollTop = document.querySelectorAll('.tree-selected')[0].offsetTop; 104 | const scrollLeft = document.querySelectorAll('.tree-selected .tree-indent')[0].offsetWidth; 105 | this.refs.treeScroller.scrollTo({ 106 | left: scrollLeft - 80, 107 | top: scrollTop - 100, 108 | behavior: "smooth", 109 | }); 110 | }) 111 | } 112 | 113 | renderLoading() { 114 | return dumpFailed ? ( 115 |
116 | Get UI Failed, location.reload()} style={{ color: '#1672f3' }}>Retry... 117 |
118 | ) : ( 119 |
Waiting Device start...
120 | ); 121 | } 122 | 123 | render() { 124 | return ( 125 |
126 |
127 | 128 | 129 |

Macaca App Inspector

130 | 135 |
136 |
137 | { 138 | this.state.tree ? ( 139 |
140 |
141 | 147 |
148 |
149 | 157 |
158 | { 159 | this.state.node ? ( 160 |
161 | 166 |
167 | ) : null 168 | } 169 |
170 | ) : this.renderLoading() 171 | } 172 | 173 |
174 | ); 175 | } 176 | } 177 | 178 | module.exports = App; 179 | -------------------------------------------------------------------------------- /assets/components/info.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import CopyToClipboard from 'react-copy-to-clipboard'; 3 | 4 | import './info.less'; 5 | 6 | const blackList = [ 7 | 'index', 8 | 'nodes', 9 | 'selected', 10 | 'open', 11 | 'state', 12 | 'nodeId', 13 | 'parentId', 14 | 'rect' 15 | ]; 16 | 17 | export default class App extends PureComponent { 18 | 19 | constructor() { 20 | super(); 21 | this.state = { 22 | copied: false 23 | }; 24 | } 25 | 26 | filter(node) { 27 | const array = Object.keys(node) 28 | .filter(key => blackList.indexOf(key) < 0 && !/^\$/.test(key)) 29 | .map(key => ({ 30 | key, text: String(node[key]) 31 | })); 32 | 33 | array.push({ 34 | key: 'xpath_lite', 35 | text: this.props.xpath_lite 36 | }, { 37 | key: 'xpath', 38 | text: this.props.xpath 39 | }); 40 | 41 | return array; 42 | } 43 | 44 | onCopy() { 45 | this.setState({ 46 | copied: true 47 | }); 48 | setTimeout(() => { 49 | this.setState({ 50 | copied: false 51 | }); 52 | }, 1000) 53 | } 54 | 55 | render() { 56 | const node = this.props.node; 57 | const blackList = []; 58 | return ( 59 | 77 | ); 78 | } 79 | 80 | }; 81 | -------------------------------------------------------------------------------- /assets/components/info.less: -------------------------------------------------------------------------------- 1 | .info { 2 | word-break: break-all; 3 | padding: 0 15px; 4 | margin-top: 23px; 5 | min-width: 300px; 6 | 7 | li { 8 | display: flex; 9 | font-size: 14px; 10 | border-top: 1px solid #eee; 11 | padding: 2px 0; 12 | 13 | > label { 14 | width: 100px; 15 | position: relative; 16 | } 17 | 18 | a { 19 | color: #333; 20 | text-decoration: none; 21 | } 22 | ._xpath:before { 23 | display: block; 24 | content: '?'; 25 | position: absolute; 26 | top: 0; 27 | left: 40px; 28 | color: rgb(255, 102, 0); 29 | font-weight: 600; 30 | cursor: pointer; 31 | } 32 | 33 | > div { 34 | flex: 1; 35 | color: #678; 36 | cursor: pointer; 37 | } 38 | 39 | } 40 | 41 | li:last-child { 42 | color: #666; 43 | opacity: 0; 44 | transition: opacity 0.8s ease; 45 | } 46 | 47 | li:nth-last-child(2) { 48 | border-bottom: 1px solid #eee; 49 | } 50 | 51 | .fadeIn { 52 | opacity: 1 !important; 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /assets/components/screen.jsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | PureComponent 3 | } from 'react'; 4 | 5 | import './screen.less'; 6 | 7 | // TODO: should by device name. 8 | function getIOSDprByScreenWidth(width) { 9 | return width > 1000 ? 3 : 2; 10 | } 11 | 12 | export default class Screen extends PureComponent { 13 | 14 | constructor() { 15 | super(); 16 | 17 | this.state = { 18 | width: 0, 19 | height: 0, 20 | frameBounds: null 21 | }; 22 | } 23 | 24 | get rate() { 25 | /** 26 | * iOS: bounds-width = 320 but image-width = 640 27 | * Android: bounds-width = 720 and image-width = 720 28 | */ 29 | return this.props.isIOS ? getIOSDprByScreenWidth(this.state.width) : 1; 30 | } 31 | 32 | handleImageLoad() { 33 | this.setState({ 34 | width: this.refs.image.naturalWidth, 35 | height: this.refs.image.naturalHeight, 36 | }, () => { 37 | console.info('iOS', this.props.isIOS); 38 | console.info('image-width-height', this.state.width, this.state.height); 39 | console.info('dpr', this.rate); 40 | }); 41 | this.initCanvas(); 42 | } 43 | 44 | displayWidth() { 45 | return 320; // css client width 46 | } 47 | 48 | paintFrame(frameBounds, style) { 49 | console.info('frameBounds', frameBounds); 50 | const rate = this.rate; 51 | const cxt = this.cxt; 52 | cxt.clearRect(0, 0, this.state.width, this.state.height); 53 | if (!frameBounds) return; 54 | 55 | cxt.fillStyle = 'red'; 56 | cxt.globalAlpha = 0.5; 57 | 58 | cxt.fillRect.apply(cxt, frameBounds.map(x => x * rate)); 59 | } 60 | 61 | handleClick(e) { 62 | const rate = this.rate; 63 | const scale = this.state.width / this.displayWidth(); 64 | 65 | this.props.onClick( 66 | (e.clientX - this.canvas.offsetLeft) * scale / rate, 67 | (e.clientY - this.canvas.offsetTop) * scale / rate 68 | ); 69 | } 70 | 71 | initCanvas() { 72 | const canvas = this.refs.canvas; 73 | this.cxt = canvas.getContext('2d'); 74 | this.canvas = canvas; 75 | } 76 | 77 | shouldComponentUpdate(nextProps, nextState) { 78 | if (nextProps.frame !== this.props.frame && this.cxt) { 79 | this.paintFrame(nextProps.frame); 80 | } 81 | return this.state !== nextState; 82 | } 83 | 84 | render() { 85 | return ( 86 |
87 | e.preventDefault()} 92 | ref="canvas" 93 | /> 94 | 98 |
99 | ); 100 | } 101 | }; 102 | 103 | Screen.defaultProps = { 104 | onClick() {}, 105 | frame: null, 106 | isIOS: true 107 | }; 108 | -------------------------------------------------------------------------------- /assets/components/screen.less: -------------------------------------------------------------------------------- 1 | .screen { 2 | font-size: 0; 3 | 4 | canvas { 5 | position: absolute; 6 | } 7 | 8 | img, canvas { 9 | width: 320px; 10 | } 11 | } -------------------------------------------------------------------------------- /assets/components/tree.jsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | PureComponent, 3 | } from 'react'; 4 | 5 | import SyntaxHighlighter from 'react-syntax-highlighter'; 6 | import { docco } from 'react-syntax-highlighter/styles/hljs'; 7 | 8 | import { 9 | className, 10 | pick, 11 | } from '../libs/utils'; 12 | 13 | import './tree.less'; 14 | 15 | const TreeNodeList = (props) => ( 16 | 30 | ); 31 | 32 | class TreeNode extends PureComponent { 33 | 34 | updateData(updater) { 35 | this.props.onUpdate(this.props.path, updater); 36 | } 37 | 38 | handleTagClick(e) { 39 | e.stopPropagation(); 40 | const data = this.props.data; 41 | 42 | this.updateData({ 43 | $open: !data.$open 44 | }); 45 | } 46 | 47 | handleSelect() { 48 | const data = this.props.data; 49 | 50 | this.updateData({ 51 | $selected: !data.$selected 52 | }); 53 | } 54 | 55 | handleMouseEnter() { 56 | this.props.onMouseEnter(this.props.data); 57 | } 58 | 59 | handleMouseLeave() { 60 | this.props.onMouseLeave(this.props.data); 61 | } 62 | 63 | tagName() { 64 | if (this.props.data.class) { 65 | return this.props.data.class.replace('android.widget.', ''); 66 | } 67 | return 'unknown_class'; 68 | } 69 | 70 | openTag() { 71 | return `<${this.tagName()}>`; 72 | } 73 | 74 | closeTag() { 75 | return ``; 76 | } 77 | 78 | render() { 79 | const props = this.props; 80 | const data = this.props.data; 81 | const childrenCount = data.nodes && data.nodes.length || 0; 82 | 83 | return ( 84 |
89 |
94 | 97 | 98 | 99 | { this.openTag(data) } 100 | 101 | { 102 | !data.$open && childrenCount ? '...' : null 103 | } 104 | { 105 | !data.$open || !childrenCount ? ( 106 | 107 | { this.closeTag(data) } 108 | 109 | ) : null 110 | } 111 |
112 | { 113 | childrenCount && data.$open ? ( 114 | [ 115 | , 123 |
129 | 132 | 133 | { this.closeTag(data) } 134 | 135 |
136 | ] 137 | ) : null 138 | } 139 |
140 | ); 141 | } 142 | 143 | } 144 | 145 | export default class Tree extends PureComponent { 146 | 147 | constructor(props) { 148 | super(); 149 | this.state = { 150 | data: this.expandShallow(props.initialData, 5), 151 | isTreeView: true, 152 | }; 153 | window.tree = this; 154 | } 155 | 156 | expandShallow(data, depth) { 157 | data.$open = true; 158 | const nodes = data.nodes; 159 | 160 | if (nodes && depth > 1) { 161 | for (let i = 0; i < nodes.length; i++) { 162 | this.expandShallow(nodes[i], depth - 1); 163 | } 164 | } 165 | 166 | return data; 167 | } 168 | 169 | handleNodeUpdate(nodePath, updater) { 170 | let root = this.state.data; 171 | let updateResult = { root }; 172 | 173 | /** 174 | * if a node will be selected, 175 | * the previous selected node (is exist) must be unselected. 176 | */ 177 | if (updater.$selected) { 178 | if (this.selectedPath) { 179 | updateResult = this.updateDate(updateResult.root, this.selectedPath, { 180 | $selected: false 181 | }); 182 | } 183 | this.selectedPath = nodePath; 184 | } 185 | 186 | /** 187 | * if a node will expand 188 | * it's all ancestors will expand too. 189 | */ 190 | const bubble = updater.$open ? { 191 | $open: true 192 | } : null; 193 | updateResult = this.updateDate(updateResult.root, nodePath, updater, bubble); 194 | 195 | this.setState({ 196 | data: updateResult.root 197 | }); 198 | 199 | if (updater.$selected) { 200 | this.props.onSelect(updateResult.node, nodePath); 201 | } 202 | } 203 | 204 | focus(nodePath) { 205 | this.handleNodeUpdate(nodePath, { 206 | $open: true, 207 | $selected: true 208 | }); 209 | } 210 | 211 | /** 212 | * root is an immutable data. each time it update, should return a diffrent one. 213 | * it will be clone along the nodePath. 214 | */ 215 | updateDate(root, nodePath, updater, bubble) { 216 | let len = nodePath.length; 217 | let node = root; 218 | let nodes; 219 | let index; 220 | 221 | let bubbleUpdater; 222 | 223 | if (bubble === true) { 224 | bubbleUpdater = updater; 225 | } else if (bubble) { 226 | bubbleUpdater = pick(updater, bubble); 227 | } 228 | 229 | for (let i = 0; i < len + 1; i++) { 230 | if (i === len) { 231 | node = Object.assign({}, node, updater); 232 | } else { 233 | node = Object.assign({}, node, bubbleUpdater); 234 | } 235 | 236 | if (nodes) { 237 | nodes[index] = node; 238 | } else { 239 | root = node; 240 | } 241 | 242 | if (i < len) { 243 | nodes = node.nodes = node.nodes.concat(); 244 | index = nodePath[i]; 245 | node = nodes[index]; 246 | } 247 | } 248 | return { 249 | root, node 250 | }; 251 | } 252 | 253 | onViewButtonClick() { 254 | this.setState({ 255 | isTreeView: !this.state.isTreeView 256 | }) 257 | } 258 | 259 | render() { 260 | return ( 261 |
264 |

{ this.state.isTreeView ? 'View Source' : 'View Tree' }

265 | { 266 | this.state.isTreeView ? 267 | : 274 | 279 | { JSON.stringify(this.state.data, null, 2) } 280 | 281 | } 282 |
283 | ); 284 | } 285 | }; 286 | 287 | Tree.defaultProps = { 288 | onSelect() {}, 289 | onHover() {}, 290 | onNodeMouseEnter() {}, 291 | onNodeMouseLeave() {}, 292 | width: null 293 | }; 294 | -------------------------------------------------------------------------------- /assets/components/tree.less: -------------------------------------------------------------------------------- 1 | .tree-list { 2 | list-style: none; 3 | margin: 0; 4 | cursor: default; 5 | } 6 | 7 | .view-source { 8 | cursor: pointer; 9 | text-align: left; 10 | margin: 0; 11 | padding-left: 5px; 12 | padding-bottom: 5px; 13 | border-bottom: 1px solid #eee; 14 | font-weight: 600; 15 | } 16 | 17 | .list-view { 18 | pre { 19 | margin: 0; 20 | } 21 | } 22 | 23 | .tree-tag-open, 24 | .tree-tag-close { 25 | height: 16px; 26 | line-height: 16px; 27 | vertical-align: top; 28 | } 29 | 30 | .tree-line { 31 | position: relative; 32 | font-size: 12px; 33 | 34 | &:hover { 35 | background: #eee; 36 | } 37 | 38 | white-space: nowrap; 39 | } 40 | 41 | .tree-indent { 42 | display: inline-block; 43 | height: 16px; 44 | padding-left: 30px; 45 | vertical-align: top; 46 | } 47 | 48 | .tree-trigger { 49 | position: absolute; 50 | visibility: hidden; 51 | display: inline-block; 52 | width: 0; 53 | height: 0; 54 | top: 50%; 55 | margin-top: -6px; 56 | margin-left: -15px; 57 | border: 5px solid transparent; 58 | border-left: 8px solid #ddd; 59 | 60 | &:hover { 61 | border-left-color: #666; 62 | } 63 | } 64 | 65 | .tree-has-child > div > .tree-trigger { 66 | visibility: visible; 67 | } 68 | 69 | .tree-open > div > .tree-trigger { 70 | transform: rotate(90deg); 71 | transform-origin: 3px 5px; 72 | } 73 | 74 | .tree-selected { 75 | > .tree-line { 76 | background: #666!important; 77 | color: #fff; 78 | } 79 | 80 | .tree-list { 81 | background: #f4f4f4; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /assets/index.jsx: -------------------------------------------------------------------------------- 1 | import 'es6-promise'; 2 | import 'whatwg-fetch'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | import './index.less'; 7 | import App from './components/index'; 8 | 9 | ReactDOM.render(, document.getElementById('app')); 10 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font: 14px/1.5 'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif; 4 | } 5 | 6 | ul { 7 | list-style: none; 8 | padding: 0; 9 | } 10 | 11 | .fn-left { 12 | float: right; 13 | } 14 | 15 | .fn-right { 16 | float: right; 17 | } 18 | 19 | .container { 20 | display: flex; 21 | flex-direction: column; 22 | position: absolute; 23 | box-sizing: border-box; 24 | top: 0; 25 | left: 0; 26 | right: 0; 27 | bottom: 0; 28 | } 29 | 30 | .main { 31 | display: flex; 32 | flex: 1; 33 | overflow: scroll; 34 | } 35 | 36 | .header { 37 | border-bottom: 1px solid #eee; 38 | padding: 10px; 39 | } 40 | 41 | .header a { 42 | text-decoration: none; 43 | } 44 | 45 | .header img { 46 | width: 50px; 47 | margin: 5px 20px; 48 | vertical-align: bottom; 49 | } 50 | 51 | .header h1 { 52 | display: inline; 53 | text-align: center; 54 | line-height: 50px; 55 | } 56 | 57 | .header .github-btn { 58 | float: right; 59 | margin-top: 15px; 60 | margin-right: 8px; 61 | } 62 | 63 | .loading { 64 | flex: 1; 65 | text-align: center; 66 | font-size: 20px; 67 | color: #aaa; 68 | padding: 100px 0; 69 | } 70 | 71 | .screen { 72 | flex: 0; 73 | padding: 0 20px; 74 | 75 | img { 76 | box-shadow: 0 0 20px rgba(0,0,0,.2); 77 | } 78 | } 79 | 80 | .flex-col { 81 | flex: 1; 82 | overflow: auto; 83 | font-size: 12px; 84 | padding: 20px 0; 85 | border-left: 1px solid #eee; 86 | 87 | &:first-child { 88 | flex: none; 89 | border-left: 0; 90 | } 91 | 92 | } 93 | 94 | h1 { 95 | color: #333; 96 | } 97 | 98 | -------------------------------------------------------------------------------- /assets/libs/bounds.js: -------------------------------------------------------------------------------- 1 | export function boundsSize(bounds) { 2 | const [ 3 | x, 4 | y, 5 | width, 6 | height 7 | ] = bounds; 8 | return width * height; 9 | }; 10 | 11 | export function compareBoundsSize(rectA, rectB) { 12 | return boundsSize(rectA) > boundsSize(rectB); 13 | }; 14 | 15 | export function isInRect(x, y, bounds) { 16 | const [ 17 | _x, 18 | _y, 19 | width, 20 | height 21 | ] = bounds; 22 | 23 | return x >= _x 24 | && x <= _x + width 25 | && y >= _y 26 | && y <= _y + height; 27 | }; 28 | 29 | export function getNodePathByXY(tree, isIOS, x, y) { 30 | let bestBounds = null; 31 | let bestPath = null; 32 | 33 | function walk(node, path) { 34 | let bounds = node.bounds; 35 | let inRect = isInRect(x, y, bounds); 36 | 37 | if (inRect) { 38 | if (!bestBounds || compareBoundsSize(bestBounds, bounds)) { 39 | bestBounds = bounds; 40 | bestPath = path; 41 | } 42 | 43 | if (node.nodes) { 44 | node.nodes.forEach((child, index) => { 45 | walk(child, path.concat([index])); 46 | }); 47 | } 48 | } 49 | } 50 | 51 | walk(tree, []); 52 | 53 | return bestPath; 54 | }; 55 | -------------------------------------------------------------------------------- /assets/libs/utils.js: -------------------------------------------------------------------------------- 1 | function isObject(obj) { 2 | return Object.prototype.toString.call(obj) === '[object Object]'; 3 | } 4 | 5 | function objectClassName(obj) { 6 | return Object.keys(obj).reduce((prev, key) => { 7 | return obj[key] ? (prev + ' ' + key) : prev; 8 | }, ''); 9 | } 10 | 11 | function arrayClassName(arr) { 12 | return arr.map(value => { 13 | return isObject(value) ? objectClassName(value) : value; 14 | }).join(' '); 15 | } 16 | 17 | /** 18 | * className for human 19 | * @example 20 | * className('button', 'primary', { disabled: true }) 21 | */ 22 | export function className(...args) { 23 | return arrayClassName(args); 24 | }; 25 | 26 | /** 27 | * clone part of object. 28 | * @example 29 | * pick({ a: 3, b: 4}, { a: true, _c: true }) 30 | * -> { a: 3} 31 | */ 32 | export function pick(dict, term) { 33 | const newer = {}; 34 | for (let key in term) { 35 | if (term[key] && dict.hasOwnProperty(key)) { 36 | newer[key] = dict[key]; 37 | } 38 | } 39 | return newer; 40 | }; 41 | -------------------------------------------------------------------------------- /assets/libs/xpath.js: -------------------------------------------------------------------------------- 1 | var arrKeyAttrs = [ 2 | 'resource-id', // Android 3 | 'rawIndentifier', // iOS 4 | 'name', // Android & iOS 5 | 'text', // Android 6 | 'value' // iOS 7 | ]; 8 | 9 | var mapIdCount = {}; 10 | var mapRawIndentifierCount = {}; 11 | var mapTextCount = {}; 12 | var mapNameCount = {}; 13 | var mapValueCount = {}; 14 | var isScan = false; 15 | 16 | const androidRootName = 'MacacaAppInspectorRoot'; 17 | 18 | function getChildIndex(node, nodes) { 19 | let index = 0; 20 | 21 | for (var i = 0; i < nodes.length; i++) { 22 | var item = nodes[i]; 23 | 24 | if (item.class === node.class) { 25 | index++; 26 | } 27 | 28 | if (node === item) { 29 | break; 30 | } 31 | } 32 | 33 | return index; 34 | } 35 | 36 | function scanNode(nodes) { 37 | 38 | if (!isScan) { 39 | 40 | if (!nodes) { 41 | return; 42 | } 43 | 44 | for (let i = 0; i < nodes.length; i++) { 45 | let current = nodes[i]; 46 | arrKeyAttrs.forEach(attr => { 47 | let value = current[attr]; 48 | 49 | if (value) { 50 | switch (attr) { 51 | case 'resource-id': 52 | mapIdCount[value] = mapIdCount[value] && mapIdCount[value] + 1 || 1; 53 | break; 54 | case 'rawIndentifier': 55 | mapRawIndentifierCount[value] = mapRawIndentifierCount[value] && mapRawIndentifierCount[value] + 1 || 1; 56 | break; 57 | case 'name': 58 | mapNameCount[value] = mapNameCount[value] && mapNameCount[value] + 1 || 1; 59 | break; 60 | case 'text': 61 | mapTextCount[value] = mapTextCount[value] && mapTextCount[value] + 1 || 1; 62 | break; 63 | case 'value': 64 | mapValueCount[value] = mapValueCount[value] && mapValueCount[value] + 1 || 1; 65 | break; 66 | } 67 | } 68 | }); 69 | scanNode(current.nodes); 70 | } 71 | } 72 | } 73 | 74 | export function getXPathLite(tree, nodePath) { 75 | 76 | scanNode([tree]); 77 | isScan = true; 78 | 79 | const array = []; 80 | let nodes = [tree]; 81 | const paths = [0, ...nodePath]; 82 | 83 | let XPath = ''; 84 | 85 | for (let i = 0; i < paths.length; i++) { 86 | let current = nodes[paths[i]]; 87 | let name = current['name']; 88 | let resourceId = current['resource-id']; 89 | let text = current['text']; 90 | let value = current['value']; 91 | let rawIndentifier = current['rawIndentifier']; 92 | 93 | let index = getChildIndex(current, nodes); 94 | 95 | if (resourceId && mapIdCount[resourceId] === 1 && resourceId.trim()) { 96 | XPath = `/*[@resource-id="${resourceId.trim()}"]`; 97 | } else if (rawIndentifier && mapRawIndentifierCount[rawIndentifier] === 1 && rawIndentifier.trim()) { 98 | XPath = `/*[@name="${rawIndentifier.trim()}"]`; 99 | } else if (name && mapNameCount[name] === 1 && name.trim()) { 100 | XPath = `/*[@name="${name.trim()}"]`; 101 | } else if (text && mapTextCount[text] === 1 && text.trim()) { 102 | XPath = `/*[@text="${text.trim()}"]`; 103 | } else { 104 | if (current.class !== androidRootName) { 105 | XPath = `${XPath}/${current.class}[${index}]`; 106 | } 107 | } 108 | nodes = current.nodes; 109 | } 110 | return `/${XPath}`; 111 | }; 112 | 113 | export function getXPath(tree, nodePath) { 114 | const array = []; 115 | let nodes = [tree]; 116 | const paths = [0, ...nodePath]; 117 | 118 | for (let i = 0; i < paths.length; i++) { 119 | let current = nodes[paths[i]]; 120 | let index = getChildIndex(current, nodes); 121 | 122 | const tagName = current.class; 123 | 124 | if (current.class !== androidRootName) { 125 | array.push(`${tagName}[${index}]`); 126 | } 127 | nodes = current.nodes; 128 | } 129 | 130 | return `//${array.join('/')}`; 131 | }; 132 | -------------------------------------------------------------------------------- /bin/app-inspector.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const co = require('co'); 6 | const program = require('commander'); 7 | const update = require('npm-update'); 8 | 9 | const pkg = require('../package.json'); 10 | const _ = require('../lib/common/helper'); 11 | 12 | const { 13 | chalk, 14 | } = _; 15 | 16 | const Inspector = require('..'); 17 | 18 | const options = { 19 | port: 5678, 20 | verbose: true, 21 | }; 22 | const EOL = require('os').EOL; 23 | 24 | program 25 | .option('-p, --port ', 'port to use (5678 default)') 26 | .option('-u, --udid ', 'udid of device') 27 | .option('-s, --silent', 'start without opening browser') 28 | .option('--verbose', 'show more debugging information') 29 | .option('-v, --versions', 'output version infomation') 30 | .usage(''); 31 | 32 | program.parse(process.argv); 33 | 34 | const printInfo = function(lines) { 35 | let maxLength = 0; 36 | lines.forEach(line => { 37 | maxLength = line.length > maxLength ? line.length : maxLength; 38 | }); 39 | 40 | const res = [ new Array(maxLength + 7).join('*') ]; 41 | 42 | lines.forEach(line => { 43 | res.push(`* ${line + new Array(maxLength - line.length + 1).join(' ')} *`); 44 | }); 45 | 46 | res.push(new Array(maxLength + 7).join('*')); 47 | console.log(chalk.white(`${EOL}${res.join(EOL)}${EOL}`)); 48 | }; 49 | 50 | // eslint-disable-next-line handle-callback-err 51 | function init(error, data) { 52 | if (data && data.version && pkg.version !== data.version) { 53 | printInfo([ `version ${pkg.version} is outdate`, `run: npm i -g ${pkg.name}@${data.version}` ]); 54 | } 55 | 56 | if (program.versions) { 57 | console.info(`${EOL} ${chalk.grey(pkg.version)} ${EOL}`); 58 | process.exit(0); 59 | } 60 | 61 | if (!program.udid) { 62 | program.help(); 63 | process.exit(0); 64 | } 65 | 66 | _.merge(options, _.getConfig(program)); 67 | 68 | co(Inspector, options).catch(e => { 69 | console.log(e); 70 | }); 71 | } 72 | 73 | co(update, { 74 | pkg, 75 | callback: init, 76 | }); 77 | -------------------------------------------------------------------------------- /css/cayman.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; } 3 | 4 | body { 5 | padding: 0; 6 | margin: 0; 7 | /* http://zenozeng.github.io/fonts.css/ */ 8 | font-family: "Open Sans", "Helvetica Neue", Helvetica, "Nimbus Sans L", Arial, "Liberation Sans", "PingFang SC", "Hiragino Sans GB", "Source Han Sans CN", "Source Han Sans SC", "Microsoft YaHei", "Wenquanyi Micro Hei", "WenQuanYi Zen Hei", "ST Heiti", SimHei, "WenQuanYi Zen Hei Sharp", sans-serif; 9 | font-size: 16px; 10 | line-height: 1.5; 11 | color: #606c71; } 12 | 13 | a { 14 | color: #1ea8e8; 15 | text-decoration: none; } 16 | a:hover { 17 | text-decoration: underline; } 18 | 19 | .btn { 20 | display: inline-block; 21 | margin-bottom: 1rem; 22 | color: rgba(255, 255, 255, 0.7); 23 | background-color: rgba(255, 255, 255, 0.08); 24 | border-color: rgba(255, 255, 255, 0.2); 25 | border-style: solid; 26 | border-width: 1px; 27 | border-radius: 0.3rem; 28 | transition: color 0.2s, background-color 0.2s, border-color 0.2s; } 29 | .btn:hover { 30 | color: rgba(255, 255, 255, 0.9); 31 | text-decoration: none; 32 | background-color: rgba(255, 255, 255, 0.2); 33 | border-color: rgba(255, 255, 255, 0.5); } 34 | .btn + .btn { 35 | margin-left: 1rem; } 36 | @media screen and (min-width: 64em) { 37 | .btn { 38 | padding: 0.75rem 1rem; } } 39 | @media screen and (min-width: 42em) and (max-width: 64em) { 40 | .btn { 41 | padding: 0.6rem 0.9rem; 42 | font-size: 0.9rem; } } 43 | @media screen and (max-width: 42em) { 44 | .btn { 45 | display: block; 46 | width: 100%; 47 | padding: 0.75rem; 48 | font-size: 0.9rem; } 49 | .btn + .btn { 50 | margin-top: 1rem; 51 | margin-left: 0; } } 52 | 53 | .page-header { 54 | color: #fff; 55 | text-align: center; 56 | background-color: #28ded6; 57 | background-image: linear-gradient(120deg, #1ea8e8, #1ed1da);} 58 | 59 | @media screen and (min-width: 64em) { 60 | .page-header { 61 | padding: 5rem 6rem; } } 62 | @media screen and (min-width: 42em) and (max-width: 64em) { 63 | .page-header { 64 | padding: 3rem 4rem; } } 65 | @media screen and (max-width: 42em) { 66 | .page-header { 67 | padding: 2rem 1rem; } } 68 | 69 | .project-name { 70 | margin-top: 0; 71 | margin-bottom: 0.1rem; } 72 | @media screen and (min-width: 64em) { 73 | .project-name { 74 | font-size: 3.25rem; } } 75 | @media screen and (min-width: 42em) and (max-width: 64em) { 76 | .project-name { 77 | font-size: 2.25rem; } } 78 | @media screen and (max-width: 42em) { 79 | .project-name { 80 | font-size: 1.75rem; } } 81 | 82 | .project-tagline { 83 | margin-bottom: 2rem; 84 | font-weight: normal; 85 | opacity: 0.9; } 86 | @media screen and (min-width: 64em) { 87 | .project-tagline { 88 | font-size: 1.25rem; } } 89 | @media screen and (min-width: 42em) and (max-width: 64em) { 90 | .project-tagline { 91 | font-size: 1.15rem; } } 92 | @media screen and (max-width: 42em) { 93 | .project-tagline { 94 | font-size: 1rem; } } 95 | 96 | .main-content { 97 | word-wrap: break-word; } 98 | .main-content :first-child { 99 | margin-top: 0; } 100 | @media screen and (min-width: 64em) { 101 | .main-content { 102 | max-width: 64rem; 103 | padding: 2rem 6rem; 104 | margin: 0 auto; 105 | font-size: 1.1rem; } } 106 | @media screen and (min-width: 42em) and (max-width: 64em) { 107 | .main-content { 108 | padding: 2rem 4rem; 109 | font-size: 1.1rem; } } 110 | @media screen and (max-width: 42em) { 111 | .main-content { 112 | padding: 2rem 1rem; 113 | font-size: 1rem; } } 114 | .main-content img { 115 | max-width: 100%; } 116 | .main-content h1, 117 | .main-content h2, 118 | .main-content h3, 119 | .main-content h4, 120 | .main-content h5, 121 | .main-content h6 { 122 | margin-top: 2rem; 123 | margin-bottom: 1rem; 124 | font-weight: normal; 125 | color: #59bcc1; } 126 | .main-content p { 127 | margin-bottom: 1em; } 128 | .main-content code { 129 | padding: 2px 4px; 130 | font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; 131 | font-size: 0.9rem; 132 | color: #567482; 133 | background-color: #f3f6fa; 134 | border-radius: 0.3rem; } 135 | .main-content pre { 136 | padding: 0.8rem; 137 | margin-top: 0; 138 | margin-bottom: 1rem; 139 | font: 1rem Consolas, "Liberation Mono", Menlo, Courier, monospace; 140 | color: #567482; 141 | word-wrap: normal; 142 | background-color: #f3f6fa; 143 | border: solid 1px #dce6f0; 144 | border-radius: 0.3rem; } 145 | .main-content pre > code { 146 | padding: 0; 147 | margin: 0; 148 | font-size: 0.9rem; 149 | color: #567482; 150 | word-break: normal; 151 | white-space: pre; 152 | background: transparent; 153 | border: 0; } 154 | .main-content .highlight { 155 | margin-bottom: 1rem; } 156 | .main-content .highlight pre { 157 | margin-bottom: 0; 158 | word-break: normal; } 159 | .main-content .highlight pre, 160 | .main-content pre { 161 | padding: 0.8rem; 162 | overflow: auto; 163 | font-size: 0.9rem; 164 | line-height: 1.45; 165 | border-radius: 0.3rem; 166 | -webkit-overflow-scrolling: touch; } 167 | .main-content pre code, 168 | .main-content pre tt { 169 | display: inline; 170 | max-width: initial; 171 | padding: 0; 172 | margin: 0; 173 | overflow: initial; 174 | line-height: inherit; 175 | word-wrap: normal; 176 | background-color: transparent; 177 | border: 0; } 178 | .main-content pre code:before, .main-content pre code:after, 179 | .main-content pre tt:before, 180 | .main-content pre tt:after { 181 | content: normal; } 182 | .main-content ul, 183 | .main-content ol { 184 | margin-top: 0; } 185 | .main-content blockquote { 186 | padding: 0 1rem; 187 | margin-left: 0; 188 | color: #819198; 189 | border-left: 0.3rem solid #dce6f0; } 190 | .main-content blockquote > :first-child { 191 | margin-top: 0; } 192 | .main-content blockquote > :last-child { 193 | margin-bottom: 0; } 194 | .main-content table { 195 | display: block; 196 | width: 100%; 197 | overflow: auto; 198 | word-break: normal; 199 | word-break: keep-all; 200 | -webkit-overflow-scrolling: touch; } 201 | .main-content table th { 202 | font-weight: bold; } 203 | .main-content table th, 204 | .main-content table td { 205 | padding: 0.5rem 1rem; 206 | border: 1px solid #e9ebec; } 207 | .main-content dl { 208 | padding: 0; } 209 | .main-content dl dt { 210 | padding: 0; 211 | margin-top: 1rem; 212 | font-size: 1rem; 213 | font-weight: bold; } 214 | .main-content dl dd { 215 | padding: 0; 216 | margin-bottom: 1rem; } 217 | .main-content hr { 218 | height: 2px; 219 | padding: 0; 220 | margin: 1rem 0; 221 | background-color: #eff0f1; 222 | border: 0; } 223 | 224 | .site-footer { 225 | text-align: center; 226 | padding-top: 2rem; 227 | padding-bottom: 1rem; 228 | margin-top: 2rem; 229 | border-top: solid 1px #eff0f1; } 230 | @media screen and (min-width: 64em) { 231 | .site-footer { 232 | font-size: 1rem; } } 233 | @media screen and (min-width: 42em) and (max-width: 64em) { 234 | .site-footer { 235 | font-size: 1rem; } } 236 | @media screen and (max-width: 42em) { 237 | .site-footer { 238 | font-size: 0.9rem; } } 239 | 240 | .site-footer-owner { 241 | display: block; 242 | font-weight: bold; } 243 | 244 | .site-footer-credits { 245 | color: #819198; } 246 | -------------------------------------------------------------------------------- /css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | box-sizing: content-box; 213 | height: 0; 214 | } 215 | 216 | /** 217 | * Contain overflow in all browsers. 218 | */ 219 | 220 | pre { 221 | overflow: auto; 222 | } 223 | 224 | /** 225 | * Address odd `em`-unit font size rendering in all browsers. 226 | */ 227 | 228 | code, 229 | kbd, 230 | pre, 231 | samp { 232 | font-family: monospace, monospace; 233 | font-size: 1em; 234 | } 235 | 236 | /* Forms 237 | ========================================================================== */ 238 | 239 | /** 240 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 241 | * styling of `select`, unless a `border` property is set. 242 | */ 243 | 244 | /** 245 | * 1. Correct color not being inherited. 246 | * Known issue: affects color of disabled elements. 247 | * 2. Correct font properties not being inherited. 248 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 249 | */ 250 | 251 | button, 252 | input, 253 | optgroup, 254 | select, 255 | textarea { 256 | color: inherit; /* 1 */ 257 | font: inherit; /* 2 */ 258 | margin: 0; /* 3 */ 259 | } 260 | 261 | /** 262 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 263 | */ 264 | 265 | button { 266 | overflow: visible; 267 | } 268 | 269 | /** 270 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 271 | * All other form control elements do not inherit `text-transform` values. 272 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 273 | * Correct `select` style inheritance in Firefox. 274 | */ 275 | 276 | button, 277 | select { 278 | text-transform: none; 279 | } 280 | 281 | /** 282 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 283 | * and `video` controls. 284 | * 2. Correct inability to style clickable `input` types in iOS. 285 | * 3. Improve usability and consistency of cursor style between image-type 286 | * `input` and others. 287 | */ 288 | 289 | button, 290 | html input[type="button"], /* 1 */ 291 | input[type="reset"], 292 | input[type="submit"] { 293 | -webkit-appearance: button; /* 2 */ 294 | cursor: pointer; /* 3 */ 295 | } 296 | 297 | /** 298 | * Re-set default cursor for disabled elements. 299 | */ 300 | 301 | button[disabled], 302 | html input[disabled] { 303 | cursor: default; 304 | } 305 | 306 | /** 307 | * Remove inner padding and border in Firefox 4+. 308 | */ 309 | 310 | button::-moz-focus-inner, 311 | input::-moz-focus-inner { 312 | border: 0; 313 | padding: 0; 314 | } 315 | 316 | /** 317 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 318 | * the UA stylesheet. 319 | */ 320 | 321 | input { 322 | line-height: normal; 323 | } 324 | 325 | /** 326 | * It's recommended that you don't attempt to style these elements. 327 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 328 | * 329 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 330 | * 2. Remove excess padding in IE 8/9/10. 331 | */ 332 | 333 | input[type="checkbox"], 334 | input[type="radio"] { 335 | box-sizing: border-box; /* 1 */ 336 | padding: 0; /* 2 */ 337 | } 338 | 339 | /** 340 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 341 | * `font-size` values of the `input`, it causes the cursor style of the 342 | * decrement button to change from `default` to `text`. 343 | */ 344 | 345 | input[type="number"]::-webkit-inner-spin-button, 346 | input[type="number"]::-webkit-outer-spin-button { 347 | height: auto; 348 | } 349 | 350 | /** 351 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 352 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 353 | * (include `-moz` to future-proof). 354 | */ 355 | 356 | input[type="search"] { 357 | -webkit-appearance: textfield; /* 1 */ /* 2 */ 358 | box-sizing: content-box; 359 | } 360 | 361 | /** 362 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 363 | * Safari (but not Chrome) clips the cancel button when the search input has 364 | * padding (and `textfield` appearance). 365 | */ 366 | 367 | input[type="search"]::-webkit-search-cancel-button, 368 | input[type="search"]::-webkit-search-decoration { 369 | -webkit-appearance: none; 370 | } 371 | 372 | /** 373 | * Define consistent border, margin, and padding. 374 | */ 375 | 376 | fieldset { 377 | border: 1px solid #c0c0c0; 378 | margin: 0 2px; 379 | padding: 0.35em 0.625em 0.75em; 380 | } 381 | 382 | /** 383 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 384 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 385 | */ 386 | 387 | legend { 388 | border: 0; /* 1 */ 389 | padding: 0; /* 2 */ 390 | } 391 | 392 | /** 393 | * Remove default vertical scrollbar in IE 8/9/10/11. 394 | */ 395 | 396 | textarea { 397 | overflow: auto; 398 | } 399 | 400 | /** 401 | * Don't inherit the `font-weight` (applied by a rule above). 402 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 403 | */ 404 | 405 | optgroup { 406 | font-weight: bold; 407 | } 408 | 409 | /* Tables 410 | ========================================================================== */ 411 | 412 | /** 413 | * Remove most spacing between table cells. 414 | */ 415 | 416 | table { 417 | border-collapse: collapse; 418 | border-spacing: 0; 419 | } 420 | 421 | td, 422 | th { 423 | padding: 0; 424 | } 425 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const macacaEcosystem = require('macaca-ecosystem'); 4 | const traceFragment = require('macaca-ecosystem/lib/trace-fragment'); 5 | 6 | const name = 'app-inspector'; 7 | 8 | module.exports = { 9 | dest: 'docs_dist', 10 | base: `/${name}/`, 11 | 12 | locales: { 13 | '/': { 14 | lang: 'en-US', 15 | title: 'App Inspector', 16 | description: 'Mobile UI viewer in browser, view the UI in a tree view, and generate XPath automatically', 17 | }, 18 | '/zh/': { 19 | lang: 'zh-CN', 20 | title: 'App Inspector', 21 | description: '浏览器端的移动设备 UI 查看器,使用树状态结构查看 UI 布局,自动生成 XPath', 22 | }, 23 | }, 24 | head: [ 25 | ['script', { 26 | async: true, 27 | src: 'https://www.googletagmanager.com/gtag/js?id=UA-49226133-2', 28 | }, ''], 29 | ['script', {}, ` 30 | window.dataLayer = window.dataLayer || []; 31 | function gtag(){dataLayer.push(arguments);} 32 | gtag('js', new Date()); 33 | gtag('config', 'UA-49226133-2'); 34 | `], 35 | ['script', {}, traceFragment], 36 | ], 37 | serviceWorker: true, 38 | themeConfig: { 39 | repo: `macacajs/${name}`, 40 | editLinks: true, 41 | docsDir: 'docs', 42 | locales: { 43 | '/': { 44 | label: 'English', 45 | selectText: 'Languages', 46 | editLinkText: 'Edit this page on GitHub', 47 | lastUpdated: 'Last Updated', 48 | serviceWorker: { 49 | updatePopup: { 50 | message: 'New content is available.', 51 | buttonText: 'Refresh', 52 | }, 53 | }, 54 | nav: [ 55 | { 56 | text: 'Guide', 57 | link: '/guide/install.html' 58 | }, 59 | macacaEcosystem.en, 60 | ], 61 | sidebar: { 62 | '/guide/': genSidebarConfig('Guide') 63 | } 64 | }, 65 | '/zh/': { 66 | label: '简体中文', 67 | selectText: '选择语言', 68 | editLinkText: '在 GitHub 上编辑此页', 69 | lastUpdated: '上次更新', 70 | serviceWorker: { 71 | updatePopup: { 72 | message: '发现新内容可用', 73 | buttonText: '刷新', 74 | }, 75 | }, 76 | nav: [ 77 | { 78 | text: '指南', 79 | link: '/zh/guide/install.html' 80 | }, 81 | macacaEcosystem.zh, 82 | ], 83 | sidebar: { 84 | '/zh/guide/': genSidebarConfig('指南') 85 | } 86 | }, 87 | }, 88 | }, 89 | }; 90 | 91 | function genSidebarConfig(title) { 92 | return [ 93 | { 94 | title, 95 | collapsable: false, 96 | children: [ 97 | 'install', 98 | 'quick-start', 99 | 'get-device-id' 100 | ], 101 | }, 102 | ]; 103 | } 104 | -------------------------------------------------------------------------------- /docs/.vuepress/override.styl: -------------------------------------------------------------------------------- 1 | $textColor = #2c3e50 2 | $borderColor = #eaecef 3 | $accentColor = #ee6723 4 | $codeBgColor = #282c34 -------------------------------------------------------------------------------- /docs/.vuepress/public/assets/6d308bd9gy1fg7cnt9hf6j20t70h7782.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macacajs/app-inspector/bb0df518f903c6c17a6d5fe3473b4f90489aa03f/docs/.vuepress/public/assets/6d308bd9gy1fg7cnt9hf6j20t70h7782.jpg -------------------------------------------------------------------------------- /docs/.vuepress/public/assets/7dfcf2f7gw1f77ev6csw5g20s50iwe81.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macacajs/app-inspector/bb0df518f903c6c17a6d5fe3473b4f90489aa03f/docs/.vuepress/public/assets/7dfcf2f7gw1f77ev6csw5g20s50iwe81.gif -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | home: true 4 | heroImage: https://macacajs.github.io/logo/macaca.svg 5 | actionText: Try it Out → 6 | actionLink: /guide/install.html 7 | features: 8 | - title: Cross Platform 9 | details: Support iOS and Android platforms 10 | footer: MIT Licensed | Copyright © 2015-present Macaca 11 | 12 | --- 13 | 14 | ## Fast start 15 | 16 | ```bash 17 | # install 18 | $ npm i app-inspector -g 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/guide/get-device-id.md: -------------------------------------------------------------------------------- 1 | # Get the Device ID 2 | 3 | ## iOS 4 | 5 | ### From command line 6 | 7 | ```bash 8 | $ xcrun simctl list 9 | ``` 10 | 11 | The command above will list all your iOS simulator devices infomation. Your can find the UDID like XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX. 12 | 13 | ### From Xcode 14 | 15 | Open your simulator, choose **Hardware - devices - manage devices**. You will find the identifier in device information. 16 | 17 | ## Android 18 | 19 | ### From command line 20 | 21 | Launch your device firstly, then use adb to list all your devices. 22 | 23 | ```bash 24 | $ adb devices 25 | 26 | 123ABCDEFG device 27 | 192.168.0.100:5555 device 28 | ``` 29 | 30 | ## iOS Real Device 31 | 32 | ```bash 33 | $ DEVELOPMENT_TEAM_ID=TEAM_ID npm i app-inspector -g 34 | ``` 35 | 36 | ![](/app-inspector/assets/6d308bd9gy1fg7cnt9hf6j20t70h7782.jpg) -------------------------------------------------------------------------------- /docs/guide/install.md: -------------------------------------------------------------------------------- 1 | # Install 2 | 3 | ## Requirements 4 | 5 | To install app-inspector, [Node.js](https://nodejs.org) environment is required. 6 | 7 | [macaca-cli](https://macacajs.github.io/guide/environment-setup.html) is recommended to install. 8 | 9 | ```base 10 | $ npm install macaca-cli -g 11 | ``` 12 | 13 | Your environment needs to be setup for the particular mobile platforms that you want to view. 14 | 15 | Refer to [Macaca Environment Setup doc](https://macacajs.github.io/guide/environment-setup.html) to install Android SDK for Android, Xcode for iOS. 16 | 17 | Verify the environment with macaca-cli. 18 | 19 | ```bash 20 | $ macaca doctor 21 | ``` 22 | 23 | If you saw some green log information, it means your platform environment is ready. Then you can install app-inspector and use it. 24 | 25 | ## Installation 26 | 27 | ```base 28 | $ npm install app-inspector -g 29 | ``` 30 | 31 | -------------------------------------------------------------------------------- /docs/guide/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | ## Launch from cli 4 | 5 | ```bash 6 | $ app-inspector -u YOUR-DEVICE-ID 7 | ``` 8 | 9 | See Get the Device ID to lean how to get the device ID. 10 | 11 | ## Open Interface 12 | 13 | You will see the log information like below: 14 | 15 | > inspector start at: http://192.168.10.100:5678 16 | 17 | Then open the link http://192.168.10.100:5678 in your browser. 18 | 19 | ![](/app-inspector/assets/7dfcf2f7gw1f77ev6csw5g20s50iwe81.gif) -------------------------------------------------------------------------------- /docs/zh/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | home: true 4 | heroImage: https://macacajs.github.io/logo/macaca.svg 5 | actionText: 快速上手 6 | actionLink: /zh/guide/install.html 7 | features: 8 | - title: 多端支持 9 | details: 支持iOS 以及安卓平台 10 | footer: MIT Licensed | Copyright © 2015-present Macaca 11 | 12 | --- 13 | 14 | ## 准备起航 15 | 16 | ```bash 17 | # 安装 18 | $ npm i app-inspector -g 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/zh/guide/get-device-id.md: -------------------------------------------------------------------------------- 1 | # 获取设备 ID 2 | 3 | ## iOS 4 | 5 | ### 命令行方式 6 | 7 | ```bash 8 | $ xcrun simctl list 9 | ``` 10 | 11 | 这行命令会列出你的所以模拟器信息,里面有类似 XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX 的代码,就是模拟器 UDID。 12 | 13 | ### 从 Xcode 获取 14 | 15 | 打开模拟器,从菜单中打开 **Hardware - devices - manage devices**。 然后你会看到模拟器信息界面,里面有个 identifier,就是 UDID。 16 | 17 | ## Android 18 | 19 | ### 从命令行 20 | 21 | 先启动你的设备,然后使用 adb 命令查看设备信息: 22 | 23 | ```bash 24 | $ adb devices 25 | 26 | 123ABCDEFG device 27 | 192.168.0.100:5555 device 28 | ``` 29 | 30 | ## iOS 真机问题 31 | 32 | ```bash 33 | $ DEVELOPMENT_TEAM_ID=TEAM_ID npm i app-inspector -g 34 | ``` 35 | 36 | ![](/app-inspector/assets/6d308bd9gy1fg7cnt9hf6j20t70h7782.jpg) 37 | -------------------------------------------------------------------------------- /docs/zh/guide/install.md: -------------------------------------------------------------------------------- 1 | # 安装 2 | 3 | ## 环境需要 4 | 5 | 要安装 app-inspector, 你需要首先安装 [Node.js](https://nodejs.org)。 国内用户可以安装 [cnpm](https://npm.taobao.org/) 加快 NPM 模块安装速度。 6 | 7 | 另外,推荐安装 [macaca-cli](https://macacajs.github.io/zh/guide/environment-setup.html). 8 | 9 | ```base 10 | $ npm install macaca-cli -g 11 | ``` 12 | 13 | 你需要准备好你需要进行查看的移动平台的环境。 14 | 15 | 请参考[Macaca 环境配置文档](https://macacajs.github.io/zh/guide/environment-setup.html)。Android 请安装 Android SDK,iOS 安装 Xcode。 16 | 17 | 然后使用 macaca 命令行工具检测环境是否准备好。 18 | 19 | ```bash 20 | $ macaca doctor 21 | ``` 22 | 23 | 如果你看到一堆绿色的文字输出了,说明你的这个环境是 OK 的。然后你就可以安装使用 app-inspector。 24 | 25 | ## 安装 26 | 27 | ```base 28 | $ npm install app-inspector -g 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/zh/guide/quick-start.md: -------------------------------------------------------------------------------- 1 | # 快速开始 2 | 3 | ## 从命令行启动 4 | 5 | ```bash 6 | $ app-inspector -u YOUR-DEVICE-ID 7 | ``` 8 | 9 | 关于如何获取设备 ID,请查看 获取设备 ID 部分。 10 | 11 | ## 打开界面 12 | 13 | 你的命令行将输出如下的文字: 14 | 15 | > inspector start at: http://192.168.10.100:5678 16 | 17 | 然后在浏览器里面打开输出的链接:http://192.168.10.100:5678。推荐用 Chrome 浏览器。 18 | 19 | ![](/app-inspector/assets/7dfcf2f7gw1f77ev6csw5g20s50iwe81.gif) 20 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./lib/app-inspector'); 4 | -------------------------------------------------------------------------------- /issue_template.md: -------------------------------------------------------------------------------- 1 | Environment check: 2 | 3 | ```bash 4 | $ npm i macaca-cli -g && macaca doctor 5 | ``` 6 | 7 | --- 8 | 9 | - app inspector version / 版本号: 10 | 11 | 12 | - os version / 系统: 13 | 14 | 15 | - device information / 设备版本: 16 | 17 | 18 | - terminal log / 终端输出信息: 19 | 20 | > try `app-inspector -u xxxx --verbose` for more log detail. 21 | 22 | -------------------------------------------------------------------------------- /lib/android.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const ADB = require('macaca-adb'); 6 | const xml2map = require('xml2map2'); 7 | const UIAutomatorWD = require('uiautomatorwd'); 8 | 9 | const _ = require('./common/helper'); 10 | const logger = require('./common/logger'); 11 | 12 | let adb; 13 | let uiautomator; 14 | 15 | exports.dumpXMLAndScreenShot = function* () { 16 | yield adb.shell(`touch ${ADB.ANDROID_TMP_DIR}/macaca-dump.xml`); 17 | const result = yield uiautomator.sendCommand('/wd/hub/session/:sessionId/source', 'get', null); 18 | let xml = result.value; 19 | xml = xml.replace(/content-desc=\"\"/g, 'content-desc="null"'); 20 | const tempDir = path.join(__dirname, '..', '.temp'); 21 | _.mkdir(tempDir); 22 | const xmlFilePath = path.join(tempDir, 'android.json'); 23 | const { 24 | hierarchy, 25 | } = xml2map.tojson(xml); 26 | 27 | const adaptor = function(node) { 28 | if (node.bounds) { 29 | const bounds = node.bounds.match(/[\d\.]+/g); 30 | 31 | // [ x, y, width, height] 32 | node.bounds = [ 33 | ~~bounds[0], 34 | ~~bounds[1], 35 | bounds[2] - bounds[0], 36 | bounds[3] - bounds[1], 37 | ]; 38 | } 39 | 40 | if (node.node) { 41 | node.nodes = node.node.length ? node.node : [ node.node ]; 42 | node.nodes.forEach(adaptor); 43 | delete node.node; 44 | } 45 | return node; 46 | }; 47 | 48 | const matchedNode = _.findLast(hierarchy.node, i => { 49 | return ( 50 | i !== null && 51 | typeof i === 'object' && 52 | i.package !== 'com.android.systemui' 53 | ); 54 | }); 55 | 56 | let data; 57 | 58 | try { 59 | data = adaptor(matchedNode); 60 | } finally { 61 | !data && _.reportNodesError(xml); 62 | } 63 | 64 | fs.writeFileSync(xmlFilePath, JSON.stringify(data), 'utf8'); 65 | logger.info(`Dump Android XML success, save to ${xmlFilePath}`); 66 | 67 | const remoteFile = `${ADB.ANDROID_TMP_DIR}/screenshot.png`; 68 | const cmd = `/system/bin/rm ${remoteFile}; /system/bin/screencap -p ${remoteFile}`; 69 | yield adb.shell(cmd); 70 | const localPath = path.join(tempDir, 'android-screenshot.png'); 71 | yield adb.pull(remoteFile, localPath); 72 | }; 73 | 74 | exports.initDevice = function* (udid) { 75 | adb = new ADB(); 76 | adb.setDeviceId(udid); 77 | uiautomator = new UIAutomatorWD(); 78 | yield uiautomator.init(adb); 79 | logger.info(`Android device started: ${udid}`); 80 | }; 81 | -------------------------------------------------------------------------------- /lib/app-inspector.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Server = require('./server'); 4 | const _ = require('./common/helper'); 5 | const logger = require('./common/logger'); 6 | 7 | const { 8 | chalk, 9 | detectPort, 10 | } = _; 11 | 12 | function* parseOptions(options) { 13 | const port = yield detectPort(options.port); 14 | 15 | if (port !== parseInt(options.port, 10)) { 16 | logger.info('port: %d was occupied, changed port: %d', options.port, port); 17 | options.port = port; 18 | } 19 | } 20 | 21 | function* initDevice(options) { 22 | const udid = options.udid; 23 | const isIOS = _.getDeviceInfo(udid).isIOS; 24 | 25 | if (isIOS) { 26 | yield require('./ios').initDevice(udid); 27 | } else { 28 | yield require('./android').initDevice(udid); 29 | } 30 | } 31 | 32 | function* openBrowser(url) { 33 | const platform = process.platform; 34 | const linuxShell = platform === 'linux' ? 'xdg-open' : 'open'; 35 | const openShell = platform === 'win32' ? 'start' : linuxShell; 36 | yield _.exec(`${openShell} ${url}`); 37 | } 38 | 39 | module.exports = function* (options) { 40 | try { 41 | yield parseOptions(options); 42 | const server = new Server(options); 43 | yield server.start(); 44 | const url = `http://${_.ipv4}:${options.port}`; 45 | logger.debug(`server start at: ${url}`); 46 | yield initDevice(options); 47 | global.serverStarted = true; 48 | logger.info(`inspector start at: ${chalk.white(url)}`); 49 | 50 | if (!options.silent) { 51 | yield openBrowser(url); 52 | } 53 | } catch (e) { 54 | console.log(e); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /lib/common/helper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const utils = require('xutil'); 4 | const request = require('request'); 5 | const childProcess = require('child_process'); 6 | 7 | const logger = require('./logger'); 8 | 9 | const _ = utils.merge({}, utils); 10 | 11 | _.exec = function(cmd, opts) { 12 | return new Promise(function(resolve, reject) { 13 | childProcess.exec(cmd, _.merge({ 14 | maxBuffer: 1024 * 512, 15 | wrapArgs: false, 16 | }, opts || {}), function(err, stdout) { 17 | if (err) { 18 | return reject(err); 19 | } 20 | resolve(_.trim(stdout)); 21 | }); 22 | }); 23 | }; 24 | 25 | _.spawn = function() { 26 | const args = Array.prototype.slice.call(arguments); 27 | return new Promise((resolve, reject) => { 28 | let stdout = ''; 29 | let stderr = ''; 30 | const child = childProcess.spawn.apply(childProcess, args); 31 | 32 | child.stdout.setEncoding('utf8'); 33 | child.stderr.setEncoding('utf8'); 34 | 35 | child.on('error', error => { 36 | reject(error); 37 | }); 38 | 39 | child.stdout.on('data', data => { 40 | stdout += data; 41 | }); 42 | 43 | child.stderr.on('data', data => { 44 | stderr += data; 45 | }); 46 | 47 | child.on('close', code => { 48 | let error; 49 | if (code) { 50 | error = new Error(stderr); 51 | error.code = code; 52 | return reject(error); 53 | } 54 | resolve([ stdout, stderr ]); 55 | }); 56 | }); 57 | }; 58 | 59 | _.sleep = function(ms) { 60 | return new Promise((resolve) => { 61 | setTimeout(resolve, ms); 62 | }); 63 | }; 64 | 65 | _.retry = function(func, interval, num) { 66 | return new Promise((resolve, reject) => { 67 | func().then(resolve, err => { 68 | if (num > 0 || typeof num === 'undefined') { 69 | _.sleep(interval).then(() => { 70 | resolve(_.retry(func, interval, num - 1)); 71 | }); 72 | } else { 73 | reject(err); 74 | } 75 | }); 76 | }); 77 | }; 78 | 79 | _.request = function(url, method) { 80 | return new Promise((resolve, reject) => { 81 | method = method.toUpperCase(); 82 | 83 | const reqOpts = { 84 | url, 85 | method, 86 | headers: { 87 | 'Content-type': 'application/json;charset=UTF=8', 88 | }, 89 | resolveWithFullResponse: true, 90 | }; 91 | 92 | request(reqOpts, (error, res, body) => { 93 | if (error) { 94 | logger.debug(`xctest client proxy error with: ${error}`); 95 | return reject(error); 96 | } 97 | 98 | resolve(body); 99 | }); 100 | }); 101 | }; 102 | 103 | _.getDeviceInfo = function(udid) { 104 | return { 105 | isIOS: !!~[ 25, 36, 40 ].indexOf(udid.length), 106 | // iPhone XR (12.1) [00008020-001D4D38XXXXXXXX] 107 | isRealIOS: /^\w{40}|(\d{8}-\w{16})$/.test(udid), 108 | }; 109 | }; 110 | 111 | /** 112 | * print error information for wrong node 113 | * @param {string} source 114 | */ 115 | _.reportNodesError = function(source) { 116 | console.error(_.chalk.red( 117 | `The source may be wrong, please check your app or report with below message at: 118 | ${_.chalk.blue('https://github.com/macacajs/app-inspector/issues/new')} 119 | ****** SOURCE START ******* 120 | ${source} 121 | '****** SOURCE END *******` 122 | )); 123 | }; 124 | 125 | module.exports = _; 126 | -------------------------------------------------------------------------------- /lib/common/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const logger = require('xlogger'); 4 | 5 | const options = { 6 | logToStd: true, 7 | closeFile: true, 8 | }; 9 | 10 | module.exports = logger.Logger(options); 11 | module.exports.middleware = logger.middleware(options); 12 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.isDev = process.env.APP_INSPECTOR === 'dev'; 4 | -------------------------------------------------------------------------------- /lib/ios.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const XCTestWD = require('xctestwd'); 6 | const Simulator = require('ios-simulator'); 7 | 8 | const _ = require('./common/helper'); 9 | const logger = require('./common/logger'); 10 | 11 | const adaptor = function(node) { 12 | node.class = `XCUIElementType${node.type}`; 13 | 14 | const rect = node.rect; 15 | node.bounds = [ 16 | rect.x, 17 | rect.y, 18 | rect.width, 19 | rect.height, 20 | ]; 21 | 22 | if (node.children) { 23 | const children = node.children.length ? node.children : [ node.children ]; 24 | 25 | const nodes = []; 26 | children.forEach(child => { 27 | if (child.isVisible || child.type !== 'Window') { 28 | nodes.push(adaptor(child)); 29 | } 30 | }); 31 | 32 | node.nodes = nodes; 33 | delete node.children; 34 | } 35 | return node; 36 | }; 37 | 38 | let xctest; 39 | 40 | exports.dumpXMLAndScreenShot = function* () { 41 | const source = yield _.request(`http://${xctest.proxyHost}:${xctest.proxyPort}/wd/hub/source`, 'get', {}); 42 | const tree = JSON.parse(source).value; 43 | const tempDir = path.join(__dirname, '..', '.temp'); 44 | _.mkdir(tempDir); 45 | const xmlFilePath = path.join(tempDir, 'ios.json'); 46 | 47 | let compatibleTree; 48 | try { 49 | compatibleTree = adaptor(tree); 50 | } finally { 51 | // use finally instead of catch + throw to keep the error stack clean 52 | !compatibleTree && _.reportNodesError(source); 53 | } 54 | 55 | fs.writeFileSync(xmlFilePath, JSON.stringify(compatibleTree), 'utf8'); 56 | logger.debug(`Dump iOS XML success, save to ${xmlFilePath}`); 57 | 58 | const screenshot = yield _.request(`http://${xctest.proxyHost}:${xctest.proxyPort}/wd/hub/screenshot`, 'get', {}); 59 | const base64Data = JSON.parse(screenshot).value; 60 | const imgFilePath = path.join(tempDir, 'ios-screenshot.png'); 61 | fs.writeFileSync(imgFilePath, base64Data, 'base64'); 62 | }; 63 | 64 | exports.initDevice = function* (udid) { 65 | const isRealIOS = _.getDeviceInfo(udid).isRealIOS; 66 | 67 | let device; 68 | 69 | if (isRealIOS) { 70 | device = { 71 | deviceId: udid, 72 | }; 73 | } else { 74 | device = new Simulator({ 75 | deviceId: udid, 76 | }); 77 | } 78 | 79 | xctest = new XCTestWD({ 80 | proxyPort: 8001, 81 | device, 82 | }); 83 | 84 | try { 85 | yield xctest.start({ 86 | desiredCapabilities: {}, 87 | }); 88 | } catch (e) { 89 | console.log(e); 90 | } 91 | 92 | logger.info(`iOS device started: ${udid}`); 93 | }; 94 | -------------------------------------------------------------------------------- /lib/middlewares.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | const path = require('path'); 5 | const serve = require('koa-static'); 6 | const router = require('koa-router'); 7 | 8 | const pkg = require('../package'); 9 | const logger = require('./common/logger'); 10 | 11 | module.exports = app => { 12 | const string = `${pkg.name}/${pkg.version} node/${process.version}(${os.platform()})`; 13 | 14 | app.use(function* powerby(next) { 15 | yield next; 16 | this.set('X-Powered-By', string); 17 | }); 18 | 19 | const p = path.join(__dirname, '..', 'public'); 20 | app.use(serve(p)); 21 | app.use(serve(path.join(p, '3rdparty'))); 22 | app.use(serve(path.join(path.resolve(p, '..', '.temp')))); 23 | router(app); 24 | 25 | app.use(logger.middleware); 26 | }; 27 | -------------------------------------------------------------------------------- /lib/render.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const nunjucks = require('nunjucks'); 5 | 6 | const config = require('./config'); 7 | 8 | const env = new nunjucks.Environment( 9 | new nunjucks.FileSystemLoader(path.resolve(__dirname, '../views'), { 10 | noCache: config.isDev, 11 | }) 12 | ); 13 | 14 | env.addFilter('jstring', str => 15 | str 16 | .replace(/["'\/]/g, char => '\\' + char) 17 | .replace(/\n/g, () => '\\\n') 18 | ); 19 | 20 | module.exports = function render(path, data) { 21 | return new Promise((resolve, reject) => { 22 | env.render(path, data, (err, res) => { 23 | if (err) { 24 | reject(err); 25 | } else { 26 | resolve(res); 27 | } 28 | }); 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /lib/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Router = require('koa-router'); 4 | 5 | const pgk = require('../package'); 6 | const render = require('./render'); 7 | const _ = require('./common/helper'); 8 | 9 | const rootRouter = new Router(); 10 | 11 | module.exports = function(app) { 12 | rootRouter 13 | .get('/', function* () { 14 | const isIOS = _.getDeviceInfo(this._options.udid).isIOS; 15 | 16 | let dumpFailed = false; 17 | try { 18 | if (global.serverStarted) { 19 | if (isIOS) { 20 | yield require('./ios').dumpXMLAndScreenShot(); 21 | } else { 22 | yield require('./android').dumpXMLAndScreenShot(); 23 | } 24 | } 25 | } catch (e) { 26 | dumpFailed = true; 27 | console.error(e); 28 | } 29 | this.body = yield render('index.html', { 30 | data: { 31 | title: pgk.name, 32 | version: pgk.version, 33 | isIOS, 34 | serverStarted: global.serverStarted, 35 | dumpFailed, 36 | }, 37 | }); 38 | }); 39 | 40 | app 41 | .use(rootRouter.routes()); 42 | }; 43 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | const koa = require('koa'); 5 | const bodyParser = require('koa-bodyparser'); 6 | 7 | const router = require('./router'); 8 | const _ = require('./common/helper'); 9 | const logger = require('./common/logger'); 10 | const middlewares = require('./middlewares'); 11 | 12 | const startServer = (options) => { 13 | 14 | return new Promise((resolve, reject) => { 15 | logger.debug('server start with config:\n %j', options); 16 | 17 | try { 18 | const app = koa(); 19 | 20 | app.use(bodyParser()); 21 | 22 | app.use(function* (next) { 23 | this._options = options; 24 | yield next; 25 | }); 26 | 27 | middlewares(app); 28 | 29 | router(app); 30 | 31 | app.listen(options.port, resolve); 32 | } catch (e) { 33 | logger.debug(`server failed to start: ${e.stack}`); 34 | reject(e); 35 | } 36 | }); 37 | }; 38 | 39 | const defaultOpt = {}; 40 | 41 | function Server(options) { 42 | this.options = _.merge(defaultOpt, options || {}); 43 | this.init(); 44 | } 45 | 46 | Server.prototype.init = function() { 47 | this.options.ip = _.ipv4; 48 | this.options.host = os.hostname(); 49 | this.options.loaded_time = _.moment(Date.now()).format('YYYY-MM-DD HH:mm:ss'); 50 | }; 51 | 52 | Server.prototype.start = function() { 53 | return startServer(this.options); 54 | }; 55 | 56 | module.exports = Server; 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app-inspector", 3 | "version": "2.2.0", 4 | "description": "app inspector", 5 | "keywords": [ 6 | "inspector", 7 | "macaca" 8 | ], 9 | "bin": { 10 | "inspector": "./bin/app-inspector.js", 11 | "app-inspector": "./bin/app-inspector.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/macacajs/app-inspector.git" 16 | }, 17 | "files": [ 18 | "lib/**/*.js", 19 | "public/**/*.js", 20 | "public/**/*.css", 21 | "views/index.html", 22 | "index.js" 23 | ], 24 | "dependencies": { 25 | "co": "^4.6.0", 26 | "co-request": "^1.0.0", 27 | "commander": "^2.9.0", 28 | "detect-port": "1", 29 | "koa": "^1.2.1", 30 | "koa-bodyparser": "^2.2.0", 31 | "koa-router": "^5.4.0", 32 | "koa-static": "^2.0.0", 33 | "npm-update": "^1.0.2", 34 | "nunjucks": "^2.4.2", 35 | "react-syntax-highlighter": "^7.0.2", 36 | "request": "^2.74.0", 37 | "xlogger": "^1.0.4", 38 | "xml2map2": "^1.0.2", 39 | "xutil": "1" 40 | }, 41 | "devDependencies": { 42 | "babel-core": "^6.13.2", 43 | "babel-loader": "^7.1.5", 44 | "babel-preset-es2015": "^6.13.2", 45 | "babel-preset-react": "^6.11.1", 46 | "command-line-test": "^1.0.5", 47 | "concurrently": "^7.1.0", 48 | "cross-env": "^7.0.3", 49 | "css-loader": "^6.7.1", 50 | "es6-promise": "^3.2.1", 51 | "eslint": "8", 52 | "eslint-config-airbnb": "^19.0.4", 53 | "eslint-config-egg": "^11.0.1", 54 | "eslint-config-prettier": "^6.9.0", 55 | "eslint-plugin-babel": "^5.3.1", 56 | "eslint-plugin-mocha": "^4.11.0", 57 | "eslint-plugin-react": "^7.2.1", 58 | "git-contributor": "1", 59 | "husky": "^1.3.1", 60 | "json-loader": "^0.5.4", 61 | "koa-webpack-dev-middleware": "^1.2.2", 62 | "less": "^3.11.3", 63 | "less-loader": "^6.2.0", 64 | "macaca-ecosystem": "*", 65 | "mini-css-extract-plugin": "^2.6.0", 66 | "mocha": "6", 67 | "nyc": "^11.8.0", 68 | "react": "^15.2.1", 69 | "react-copy-to-clipboard": "^5.0.0", 70 | "react-dom": "^15.2.1", 71 | "react-ga": "^2.7.0", 72 | "react-github-button": "0.1.11", 73 | "style-loader": "^3.3.1", 74 | "terser": "^3.14.1", 75 | "vuepress": "^0.14.8", 76 | "webpack": "^5.69.1", 77 | "webpack-cli": "^4.9.0", 78 | "webpack-dev-server": "^4.8.1", 79 | "whatwg-fetch": "^1.0.0" 80 | }, 81 | "scripts": { 82 | "dev": "concurrently \"npm run watch\" \"npm run start\"", 83 | "test": "nyc --reporter=text mocha", 84 | "lint": "eslint --fix .", 85 | "start": "APP_INSPECTOR=dev ./bin/app-inspector.js -u emulator-5554 --verbose", 86 | "build": "cross-env NODE_ENV=production webpack", 87 | "watch": "cross-env NODE_ENV=development webpack serve", 88 | "prepublish": "npm run build", 89 | "contributor": "git-contributor", 90 | "docs:dev": "vuepress dev docs", 91 | "docs:build": "vuepress build docs", 92 | "prepublishOnly": "npm run build" 93 | }, 94 | "husky": { 95 | "hooks": { 96 | "pre-commit": "npm run lint" 97 | } 98 | }, 99 | "homepage": "https://github.com/macacajs/app-inspector", 100 | "bugs": "https://github.com/macacajs/app-inspector/issues/new", 101 | "license": "MIT", 102 | "optionalDependencies": { 103 | "ios-simulator": "*", 104 | "macaca-adb": "*", 105 | "uiautomatorwd": "*", 106 | "xctestwd": "*" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /public/3rdparty/react-dom.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ReactDOM v15.3.1 3 | * 4 | * Copyright 2013-present, Facebook, Inc. 5 | * All rights reserved. 6 | * 7 | * This source code is licensed under the BSD-style license found in the 8 | * LICENSE file in the root directory of this source tree. An additional grant 9 | * of patent rights can be found in the PATENTS file in the same directory. 10 | * 11 | */ 12 | !function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e(require("react"));else if("function"==typeof define&&define.amd)define(["react"],e);else{var f;f="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,f.ReactDOM=e(f.React)}}(function(e){return e.__SECRET_DOM_DO_NOT_USE_OR_YOU_WILL_BE_FIRED}); -------------------------------------------------------------------------------- /test/bin/app-inspector.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { 4 | EOL, 5 | } = require('os'); 6 | const path = require('path'); 7 | const assert = require('assert'); 8 | const CliTest = require('command-line-test'); 9 | 10 | const utils = require('./utils'); 11 | const pkg = require('../../package'); 12 | 13 | const binFile = path.resolve(pkg.bin[pkg.name]); 14 | 15 | const startString = 'inspector start at:'; 16 | 17 | describe('test/bin/app-inspector.test.js', function() { 18 | this.timeout(60 * 10 * 1000); 19 | 20 | it('`app-inspector -v` should be ok', function *() { 21 | var cliTest = new CliTest(); 22 | var res = yield cliTest.execFile(binFile, ['-v'], {}); 23 | assert.ok(res.stdout.includes(pkg.version)); 24 | }); 25 | 26 | it('`app-inspector -h` should be ok', function *() { 27 | var cliTest = new CliTest(); 28 | var res = yield cliTest.execFile(binFile, ['-h'], {}); 29 | var lines = res.stdout.trim().split(EOL); 30 | assert.ok(lines[0].includes(pkg.name)); 31 | }); 32 | 33 | it('app-inspector start should be ok', function *() { 34 | var device = yield utils.getDevices(); 35 | var res = yield utils.getOutPut(device.udid); 36 | assert.ok(res.includes(startString)); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/bin/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const ADB = require('macaca-adb'); 5 | const Simulator = require('ios-simulator'); 6 | const child_process = require('child_process'); 7 | 8 | const pkg = require('../../package'); 9 | 10 | const binFile = path.resolve(pkg.bin[pkg.name]); 11 | 12 | const isIOS = process.env.PLATFORM === 'ios'; 13 | const startString = 'inspector start at:'; 14 | 15 | exports.getDevices = function *() { 16 | var matchedDevice = null; 17 | var devices; 18 | 19 | if (isIOS) { 20 | devices = yield Simulator.getDevices(); 21 | devices.forEach(function(device) { 22 | if (device.name === 'iPhone 6' && device.available) { 23 | matchedDevice = device; 24 | } 25 | }); 26 | } else { 27 | devices = yield ADB.getDevices(); 28 | matchedDevice = devices[0]; 29 | } 30 | return matchedDevice; 31 | }; 32 | 33 | exports.getOutPut = function(udid) { 34 | console.log(`get simulator id: ${udid}`); 35 | return new Promise((resolve, reject) => { 36 | let res = ''; 37 | const child = child_process.spawn(binFile, ['-u', udid, '-s']); 38 | 39 | child.stdout.setEncoding('utf8'); 40 | child.stderr.setEncoding('utf8'); 41 | child.stdout.on('data', data => { 42 | res += data; 43 | console.log(res); 44 | if (!!~res.indexOf(startString)) { 45 | resolve(res); 46 | } 47 | }); 48 | }); 49 | }; 50 | 51 | -------------------------------------------------------------------------------- /test/lib/common/helper.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | 5 | const _ = require('../../../lib/common/helper'); 6 | 7 | describe('lib/common/helper.test.js', () => { 8 | let res; 9 | it('getDeviceInfo()', () => { 10 | res = _.getDeviceInfo('00008020-001D4D38XXXXXXXX'); 11 | assert.equal(res.isIOS, true); 12 | assert.equal(res.isRealIOS, true); 13 | res = _.getDeviceInfo('B8B6B1F5-30E0-401B-B1CE-70E4F39937A5'); 14 | assert.equal(res.isIOS, true); 15 | assert.equal(res.isRealIOS, false); 16 | }); 17 | }); -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --recursive 3 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Macaca App inspector 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 18 | 19 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const webpack = require('webpack'); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const traceFragment = require('macaca-ecosystem/lib/trace-fragment'); 7 | 8 | const pkg = require('./package'); 9 | 10 | module.exports = { 11 | entry: { 12 | index: path.resolve(__dirname, './assets/index'), 13 | }, 14 | output: { 15 | path: path.resolve(__dirname, 'public/dist'), 16 | publicPath: '/dist', 17 | filename: '[name].js', 18 | }, 19 | resolve: { 20 | extensions: [ '.jsx', '.js' ], 21 | }, 22 | externals: [ 23 | { 24 | react: 'React', 25 | 'react-dom': 'ReactDOM', 26 | }, 27 | ], 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.jsx?/, 32 | loader: 'babel-loader', 33 | exclude: /node_modules/, 34 | }, { 35 | test: /\.json$/, 36 | use: 'json-loader', 37 | type: 'javascript/auto', 38 | exclude: /node_modules/, 39 | }, 40 | { 41 | test: /\.less$/, 42 | use: [ 43 | { 44 | loader: 'style-loader', 45 | }, 46 | { 47 | loader: 'css-loader', 48 | }, 49 | { 50 | loader: 'less-loader', 51 | }, 52 | ], 53 | }, 54 | { 55 | test: /\.css$/, 56 | use: [ 57 | MiniCssExtractPlugin.loader, 58 | 'css-loader', 59 | ], 60 | }, 61 | ], 62 | }, 63 | plugins: [ 64 | new MiniCssExtractPlugin({ 65 | filename: '[name].css', 66 | chunkFilename: '[name].css', 67 | }), 68 | new webpack.DefinePlugin({ 69 | 'process.env.VERSION': JSON.stringify(pkg.version), 70 | 'process.env.traceFragment': traceFragment, 71 | }), 72 | ], 73 | devServer: { 74 | hot: true, 75 | static: { 76 | directory: __dirname, 77 | }, 78 | }, 79 | }; 80 | --------------------------------------------------------------------------------