mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(mobile-mcp): vendor mobile-mcp with opt-in 0-1000 relative coordinates (#6235)
* Squashed 'packages/mobile-mcp/' content from commit c5d7d27fd git-subtree-dir: packages/mobile-mcp git-subtree-split: c5d7d27fd61e4762e15ae4b1c68b6c011be88bb7 * feat(mobile-mcp): vendor mobile-mcp with opt-in 0-1000 relative coordinates Fork mobile-next/mobile-mcp (v0.0.61) into packages/mobile-mcp/ via git subtree, renamed to @qwen-code/mobile-mcp with the following additions: Relative coordinate shim (src/coord-norm.ts): - MOBILE_MCP_COORDINATE_SPACE=1 enables 0-1000 normalized coordinates - MOBILE_MCP_COORDINATE_SCALE configurable (default 1000, 999 for mobile_use) - Input denormalization for click/double_tap/long_press/swipe - Output normalization for list_elements and get_screen_size - Tool description rewriting when enabled - Default off = zero behavior change Android enhancements: - mobile_install_app: -r/-g/-d/-t install flags (Android only) - mobile_ui_dump: full UIAutomator XML hierarchy dump - mobile_adb_pull / mobile_adb_push: file transfer via ADB Infrastructure: - cd-mobile-mcp.yml: npm publish workflow (tag mobile-mcp-v*) - scripts/sync-from-upstream.sh: git subtree pull for upstream sync - .vendored-from / .vendored-patches.md: vendoring metadata - Upstream telemetry disabled by default - eslint.config.js: exclude packages/mobile-mcp from root lint * chore(mobile-mcp): update package-lock.json for workspace dependencies * fix(mobile-mcp): quote all YAML strings to pass yamllint * fix(mobile-mcp): fix cd workflow yaml to pass both yamllint and actionlint * fix(mobile-mcp): address review findings on our additions - ensureScreenSize: log warning instead of silent failure (#4) - invalidateScreenSize on orientation change (#5) - adb_push: path.posix.resolve to prevent /sdcard/ traversal (#6) - adb_pull: readOnlyHint → destructiveHint (writes local file) (#9) - adb_push: remove validateOutputPath on read-source local_path (#11) - normalizeElementResult: log error instead of bare catch (#16) - rewriteDescription: remove dead duplicate regex (#17) - cd workflow: add test step between build and publish (#19) * fix(mobile-mcp): update server.json identity and fix package.json main entrypoint --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
parent
b1ec04f4bd
commit
23c6c7032a
36 changed files with 13856 additions and 13 deletions
67
.github/workflows/cd-mobile-mcp.yml
vendored
Normal file
67
.github/workflows/cd-mobile-mcp.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: 'CD: mobile-mcp'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['mobile-mcp-v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to publish (without v prefix, e.g. 0.1.0)'
|
||||
required: true
|
||||
dry_run:
|
||||
description: 'Dry run (build only, no publish)'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
id-token: 'write'
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v4'
|
||||
|
||||
- uses: 'actions/setup-node@v5'
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: 'Determine version'
|
||||
id: 'version'
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/mobile-mcp-v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/mobile-mcp-v}"
|
||||
else
|
||||
VERSION="${{ inputs.version }}"
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Install dependencies'
|
||||
working-directory: 'packages/mobile-mcp'
|
||||
run: 'npm ci --ignore-scripts'
|
||||
|
||||
- name: 'Lint'
|
||||
working-directory: 'packages/mobile-mcp'
|
||||
run: 'npm run lint'
|
||||
|
||||
- name: 'Set version'
|
||||
working-directory: 'packages/mobile-mcp'
|
||||
run: 'npm version "${{ steps.version.outputs.version }}" --no-git-tag-version'
|
||||
|
||||
- name: 'Build'
|
||||
working-directory: 'packages/mobile-mcp'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Test'
|
||||
working-directory: 'packages/mobile-mcp'
|
||||
run: 'npx playwright test test/coord-norm.test.ts'
|
||||
|
||||
- name: 'Publish'
|
||||
if: '${{ !inputs.dry_run }}'
|
||||
working-directory: 'packages/mobile-mcp'
|
||||
run: 'npm publish --access public --provenance'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
|
||||
|
|
@ -34,6 +34,7 @@ export default tseslint.config(
|
|||
'.qwen/**',
|
||||
'packages/desktop/**',
|
||||
'packages/cua-driver/**', // vendored trycua/cua driver (Rust + scripts); not qwen-code TS
|
||||
'packages/mobile-mcp/**', // vendored mobile-next/mobile-mcp; has own eslint config
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
|
|
|
|||
1205
package-lock.json
generated
1205
package-lock.json
generated
File diff suppressed because it is too large
Load diff
7
packages/mobile-mcp/.gitignore
vendored
Normal file
7
packages/mobile-mcp/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
**/.DS_Store
|
||||
node_modules
|
||||
lib
|
||||
.vscode
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
1
packages/mobile-mcp/.vendored-from
Normal file
1
packages/mobile-mcp/.vendored-from
Normal file
|
|
@ -0,0 +1 @@
|
|||
c5d7d27fd
|
||||
22
packages/mobile-mcp/.vendored-patches.md
Normal file
22
packages/mobile-mcp/.vendored-patches.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Local patches on top of upstream mobile-next/mobile-mcp
|
||||
|
||||
Vendored from: `c5d7d27fd` (upstream v0.0.61, main branch)
|
||||
|
||||
Upstream sync mechanism: `git subtree pull` (see `scripts/sync-from-upstream.sh`).
|
||||
|
||||
## Local modifications
|
||||
|
||||
| Patch | Description | Touches |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| Rename to @qwen-code/mobile-mcp | npm package name, repository field | `package.json` |
|
||||
| Disable upstream telemetry | Fork doesn't phone home to upstream PostHog | `src/server.ts` |
|
||||
| Relative coordinate shim | Opt-in 0–1000 normalized coordinates (env `MOBILE_MCP_COORDINATE_SPACE`) | `src/coord-norm.ts`, `src/server.ts` |
|
||||
| InstallOptions + Android tools | Extended `installApp` with options; added `dumpUiHierarchy`, `pullFile`, `pushFile`; MCP tools `mobile_ui_dump`, `mobile_adb_pull`, `mobile_adb_push` | `src/robot.ts`, `src/android.ts`, `src/server.ts` |
|
||||
| getAndroidRobotFromDevice helper | DRY extraction for Android-only tool device validation | `src/server.ts` |
|
||||
|
||||
## When syncing upstream
|
||||
|
||||
After `git subtree pull`, expect conflicts in `src/server.ts` (our coord hooks + new tools)
|
||||
and `package.json` (name/repo). The coord shim (`src/coord-norm.ts`) is a new file and
|
||||
won't conflict. Backend files (`android.ts`, `ios.ts`, etc.) are only touched for the
|
||||
InstallOptions signature change — low conflict surface.
|
||||
240
packages/mobile-mcp/CHANGELOG.md
Normal file
240
packages/mobile-mcp/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
## [0.0.61](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.61) (2026-07-02)
|
||||
* Fix: Catch system signals for clean v8 exit ([#366](https://github.com/mobile-next/mobile-mcp/pull/366))
|
||||
* Fix: Detect go-ios installed via npm (bare semver version) ([#355](https://github.com/mobile-next/mobile-mcp/pull/355)), thanks to [@ravijagga](https://github.com/ravijagga)
|
||||
* Docs: Added CONTRIBUTING.md ([#365](https://github.com/mobile-next/mobile-mcp/pull/365))
|
||||
* Chore: Updated hono packages for security ([#367](https://github.com/mobile-next/mobile-mcp/pull/367))
|
||||
|
||||
## [0.0.60](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.60) (2026-06-15)
|
||||
* Chore: Updated mobilewright SDK version ([#361](https://github.com/mobile-next/mobile-mcp/pull/361))
|
||||
|
||||
## [0.0.59](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.59) (2026-06-09)
|
||||
* Chore: Updated hono packages for security ([#349](https://github.com/mobile-next/mobile-mcp/pull/349))
|
||||
* Chore: Updated mobilewright SDK version ([#350](https://github.com/mobile-next/mobile-mcp/pull/350))
|
||||
|
||||
## [0.0.58](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.58) (2026-06-02)
|
||||
* Fix: Understand getRobot device types and platforms ([#346](https://github.com/mobile-next/mobile-mcp/pull/346))
|
||||
* Chore: Moved tests from mocha/nyc to playwright to reduce dependency vulnerabilities ([#347](https://github.com/mobile-next/mobile-mcp/pull/347))
|
||||
|
||||
## [0.0.57](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.57) (2026-05-28)
|
||||
* Chore: Update mobilewright to 0.0.41 ([#341](https://github.com/mobile-next/mobile-mcp/pull/341))
|
||||
|
||||
## [0.0.56](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.56) (2026-05-17)
|
||||
* Chore: Update mobilewright to 0.0.38 ([#338](https://github.com/mobile-next/mobile-mcp/pull/338))
|
||||
* Fix: Restore mcp-publisher functionality ([#337](https://github.com/mobile-next/mobile-mcp/pull/337))
|
||||
|
||||
## [0.0.55](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.55) (2026-05-16)
|
||||
* General: Replace mobilecli with mobilewright SDK ([#334](https://github.com/mobile-next/mobile-mcp/pull/334))
|
||||
* General: Bump fast-xml-parser to 5.8.0 to fix security vulnerability ([#335](https://github.com/mobile-next/mobile-mcp/pull/335))
|
||||
|
||||
## [0.0.54](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.54) (2026-05-04)
|
||||
* Server: Update mobilecli to 0.3.70
|
||||
* iOS: Fixed cases where testmanagerd would get Device Kit stuck in a black screen of death
|
||||
* iOS: Added 'placeholder' to view tree response
|
||||
|
||||
## [0.0.53](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.53) (2026-05-01)
|
||||
* Server: Add `mobile_list_crashes` tool to list crash reports on a device
|
||||
* Server: Add `mobile_get_crash` tool to retrieve full crash report content
|
||||
* Server: Upgrade mobilecli from 0.2.0 to 0.3.68
|
||||
* iOS: Replaced use of WebdriverAgent with iOS Device Kit (open source, apache license)
|
||||
* CI: Restrict `contents` permission to `read`
|
||||
* CI: Remove Java setup step from build workflow
|
||||
|
||||
## [0.0.52](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.52) (2026-04-13)
|
||||
* Server: Block cross-origin requests on SSE transport ([#311](https://github.com/mobile-next/mobile-mcp/pull/311))
|
||||
* Server: Warn when SSE server starts without `MOBILEMCP_AUTH` set ([#311](https://github.com/mobile-next/mobile-mcp/pull/311))
|
||||
* Server: Reject concurrent SSE connections instead of silently replacing them ([#311](https://github.com/mobile-next/mobile-mcp/pull/311))
|
||||
* Server: Clear SSE transport on connection close to allow reconnection ([#311](https://github.com/mobile-next/mobile-mcp/pull/311))
|
||||
* Server: Update mobilecli dependency from @mobilenext/mobilecli to mobilecli 0.2.0 ([#316](https://github.com/mobile-next/mobile-mcp/pull/316))
|
||||
* CI: Fix script injection by passing `github.ref_name` through env vars ([#314](https://github.com/mobile-next/mobile-mcp/pull/314))
|
||||
* CI: Use `npm ci` instead of `npm install` for reproducible builds ([#314](https://github.com/mobile-next/mobile-mcp/pull/314))
|
||||
* CI: Pin mcp-publisher to version 1.5.0 ([#314](https://github.com/mobile-next/mobile-mcp/pull/314))
|
||||
* CI: Remove `npm update` from tag release to preserve lockfile integrity ([#314](https://github.com/mobile-next/mobile-mcp/pull/314))
|
||||
|
||||
## [0.0.51](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.51) (2026-04-03)
|
||||
* Server: Replace `--port` with `--listen` flag accepting `[host:]port` format, default to localhost ([#306](https://github.com/mobile-next/mobile-mcp/pull/306))
|
||||
* Server: Add optional Bearer token auth via `MOBILEMCP_AUTH` env variable ([#306](https://github.com/mobile-next/mobile-mcp/pull/306))
|
||||
* Server: Add `MOBILEMCP_DISABLE_TELEMETRY` env variable to opt out of anonymous telemetry ([#305](https://github.com/mobile-next/mobile-mcp/pull/305))
|
||||
* Server: Security update for path-to-regexp package ([#307](https://github.com/mobile-next/mobile-mcp/pull/307))
|
||||
|
||||
## [0.0.50](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.50) (2026-03-27)
|
||||
* Server: Restrict open_url tool to http/https schemes unless `MOBILEMCP_ALLOW_UNSAFE_URLS=1` is set ([#299](https://github.com/mobile-next/mobile-mcp/pull/299)) thanks to [@manthanghasadiya](https://github.com/manthanghasadiya) for reporting this.
|
||||
|
||||
## [0.0.49](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.49) (2026-03-24)
|
||||
* Server: Fix path traversal in save screenshot and record video ([#296](https://github.com/mobile-next/mobile-mcp/pull/296)) thanks to [@AbhiTheModder](https://github.com/AbhiTheModder) for reporting this.
|
||||
|
||||
## [0.0.48](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.48) (2026-03-20)
|
||||
* Server: Security updates for fast-xml-parser ([#292](https://github.com/mobile-next/mobile-mcp/pull/292))
|
||||
* Server: Fix handling errors in getDeviceType to prevent empty device list ([#286](https://github.com/mobile-next/mobile-mcp/pull/286)) thanks to [@ls-andy](https://github.com/ls-andy)
|
||||
|
||||
## [0.0.47](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.47) (2026-03-09)
|
||||
* Server: Use zod coerce to fix number parameter parsing ([#284](https://github.com/mobile-next/mobile-mcp/pull/284))
|
||||
* Server: Updated packages for security ([#285](https://github.com/mobile-next/mobile-mcp/pull/285))
|
||||
* iOS: Support locales when launching apps ([#283](https://github.com/mobile-next/mobile-mcp/pull/283))
|
||||
* Android: Support locales when launching apps ([#283](https://github.com/mobile-next/mobile-mcp/pull/283))
|
||||
|
||||
## [0.0.46](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.46) (2026-03-03)
|
||||
* Server: Improved tool description for listing devices, easier on prompting ([#282](https://github.com/mobile-next/mobile-mcp/pull/282))
|
||||
* iOS: Added support for screen recording for both real devices and simulators ([#282](https://github.com/mobile-next/mobile-mcp/pull/282))
|
||||
* Android: Added support for screen recording for both real devices and emulators ([#282](https://github.com/mobile-next/mobile-mcp/pull/282))
|
||||
|
||||
## [0.0.45](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.45) (2026-03-02)
|
||||
* Server: Updated fast-xml-parser package for security ([#281](https://github.com/mobile-next/mobile-mcp/pull/281))
|
||||
* Server: Fix noParams issue that started annoying Claude Code recently ([#280](https://github.com/mobile-next/mobile-mcp/pull/280))
|
||||
* Android: Fix shell escaping through launchApp ([#279](https://github.com/mobile-next/mobile-mcp/pull/279)) thanks to [@yuhanghuang](https://github.com/yuhanghuang)
|
||||
* Android: Escape url when calling openUrl ([#278](https://github.com/mobile-next/mobile-mcp/pull/278)) thanks to [yuhanghuang](https://github.com/yuhanghuang)
|
||||
|
||||
## [0.0.44](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.44) (2026-02-25)
|
||||
* General: Rolling out support for remote devices, allocate Android and iOS devices on Mobile Fleet ([#273](https://github.com/mobile-next/mobile-mcp/pull/273))
|
||||
|
||||
## [0.0.43](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.43) (2026-02-24)
|
||||
* General: Increase buffers used for screenshots, fixes bugs where screenshot was >4MB ([#270](https://github.com/mobile-next/mobile-mcp/pull/270))
|
||||
* General: Upgraded several npm packages for security ([#272](https://github.com/mobile-next/mobile-mcp/pull/272))
|
||||
|
||||
## [0.0.42](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.42) (2026-02-03)
|
||||
* General: Upgraded mobilecli to 0.0.54, [see changes](https://github.com/mobile-next/mobilecli/releases) ([ba3ec1b](https://github.com/mobile-next/mobile-mcp/commit/ba3ec1b9251487ad8444eb22fa3312c7b79d7787))
|
||||
* General: Updated fast-xml-parser package for security ([#261](https://github.com/mobile-next/mobile-mcp/pull/261))
|
||||
|
||||
## [0.0.41](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.41) (2026-01-27)
|
||||
* General: upgraded mobilecli to 0.0.52, [see changes](https://github.com/mobile-next/mobilecli/releases) ([d7e25f3](https://github.com/mobile-next/mobile-mcp/commit/d7e25f3543e87a436572c29f2b1766bd276a4d68))
|
||||
* Android: fix: include elements with resource-id or checkable attributes ([#254](https://github.com/mobile-next/mobile-mcp/pull/254)) by [@singhsume123](https://github.com/singhsume123)
|
||||
|
||||
## [0.0.40](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.40) (2026-01-15)
|
||||
* Server: bump @modelcontextprotocol/sdk from 1.24.2 to 1.25.2 for security ([#252](https://github.com/mobile-next/mobile-mcp/pull/252))
|
||||
|
||||
## [0.0.39](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.39) (2026-01-01)
|
||||
* Server: added tool annotations for improved LLM tool understanding ([#246](https://github.com/mobile-next/mobile-mcp/pull/246))
|
||||
* iOS: added 'duration' parameter to longpress for custom press durations ([#247](https://github.com/mobile-next/mobile-mcp/pull/247))
|
||||
* Android: added 'duration' parameter to longpress for custom press durations ([#247](https://github.com/mobile-next/mobile-mcp/pull/247))
|
||||
|
||||
## [0.0.38](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.38) (2025-12-09)
|
||||
* iOS: migrated iPhone Simulator calls to use mobilecli binary for improved performance ([#241](https://github.com/mobile-next/mobile-mcp/pull/241))
|
||||
* iOS: automatically downloading and installing Webdriver Agent on simulator, get started in iOS development in seconds ([#241](https://github.com/mobile-next/mobile-mcp/pull/241))
|
||||
|
||||
## [0.0.37](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.37) (2025-12-08)
|
||||
* Server: migrated to new mcp sdk tool registration API ([#239](https://github.com/mobile-next/mobile-mcp/pull/239))
|
||||
* Server: updated to @modelcontextprotocol/sdk 1.24.2 and other dependencies for security ([#239](https://github.com/mobile-next/mobile-mcp/pull/239))
|
||||
|
||||
## [0.0.36](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.36) (2025-11-19)
|
||||
* Server: upgraded libraries (glob, js-yaml) and mobilecli ([#234](https://github.com/mobile-next/mobile-mcp/pull/234))
|
||||
|
||||
## [0.0.35](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.35) (2025-11-14)
|
||||
* Server: added list of available MCP tools to README for better discoverability ([043cf3d](https://github.com/mobile-next/mobile-mcp/commit/043cf3d))
|
||||
* Android: fixed adb path resolution on Windows by always using .exe extension ([178b2fb](https://github.com/mobile-next/mobile-mcp/commit/178b2fb)) by [@mattheww-skyward](https://github.com/mattheww-skyward)
|
||||
|
||||
## [0.0.34](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.34) (2025-11-01)
|
||||
* Server: dry-run release for benchmarking how mobilecli detects devices ([#226](https://github.com/mobile-next/mobile-mcp/pull/226))
|
||||
|
||||
## [0.0.33](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.33) (2025-10-20)
|
||||
* Server: added debug information for understanding screenshot issues on old devices ([#213](https://github.com/mobile-next/mobile-mcp/pull/213))
|
||||
|
||||
## [0.0.32](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.32) (2025-10-08)
|
||||
* Server: fixed wrong separator when listing iOS simulators ([#208](https://github.com/mobile-next/mobile-mcp/pull/208))
|
||||
* iOS: double tap at screen location ([#207](https://github.com/mobile-next/mobile-mcp/pull/207))
|
||||
* Android: reduce stdout pollution by adb shell monkey ([#211](https://github.com/mobile-next/mobile-mcp/pull/211))
|
||||
* Android: fix mobile_take_screenshot on very old android devices ([#204](https://github.com/mobile-next/mobile-mcp/pull/204)) by [@boulaycote](https://github.com/boulaycote)
|
||||
* Android: double tap at screen location ([#194](https://github.com/mobile-next/mobile-mcp/pull/194)) by [@SakshamSahgal](https://github.com/SakshamSahgal)
|
||||
|
||||
## [0.0.31](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.31) (2025-10-07)
|
||||
* Server: resolve mobilecli libc issues on very old linux distros ([#206](https://github.com/mobile-next/mobile-mcp/pull/206))
|
||||
* Server: identify mcp-client for compatiblity patches ([#205](https://github.com/mobile-next/mobile-mcp/pull/205))
|
||||
|
||||
## [0.0.30](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.30) (2025-10-06)
|
||||
* Server: introduction of mobilecli tool, will replace imagemagick, sips, go-ios and adb in the future ([#196](https://github.com/mobile-next/mobile-mcp/pull/196))
|
||||
* iOS: app installation and uninstallation ([#202](https://github.com/mobile-next/mobile-mcp/pull/202))
|
||||
* Android: app installation and uninstallation ([#202](https://github.com/mobile-next/mobile-mcp/pull/202))
|
||||
|
||||
## [0.0.29](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.29) (2025-09-26)
|
||||
* Server: bumped mcp sdk to latest version ([#199](https://github.com/mobile-next/mobile-mcp/pull/199))
|
||||
* Server: locked production npm packages to specific version ([#199](https://github.com/mobile-next/mobile-mcp/pull/199))
|
||||
* Server: renamed tool 'swipe_on_screen' to 'mobile_swipe_on_screen' ([#197](https://github.com/mobile-next/mobile-mcp/pull/197))
|
||||
|
||||
## [0.0.28](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.28) (2025-09-15)
|
||||
* Server: added 'device' parameter to all tools ([#181](https://github.com/mobile-next/mobile-mcp/pull/181))
|
||||
* Server: enable agents to access multiple devices at once (eg, 'explain what's on screen on all devices connected')
|
||||
([#181](https://github.com/mobile-next/mobile-mcp/pull/181))
|
||||
|
||||
## [0.0.27](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.27) (2025-09-10)
|
||||
* Server: use 'sips' image scaling on mac if found, removes requirement to install ImageMagick for image scaling ([#188](https://github.com/mobile-next/mobile-mcp/pull/188))
|
||||
|
||||
## [0.0.26](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.26) (2025-09-09)
|
||||
* Server: support listing of mobile-mcp in github's mcp registry ([e96404e](https://github.com/mobile-next/mobile-mcp/commit/e96404e0e513e48ebcfe7956800203cc0f363526))
|
||||
|
||||
## [0.0.25](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.25) (2025-09-08)
|
||||
* Server: install mobile-mcp in vscode with a single-click in README ([#173](https://github.com/mobile-next/mobile-mcp/pull/173))
|
||||
* Android: try finding 'adb' under $HOME/Library/Android if $ANDROID_HOME is not defined ([#183](https://github.com/mobile-next/mobile-mcp/pull/183))
|
||||
* Android: better escaping of text input, for improved security ([#182](https://github.com/mobile-next/mobile-mcp/pull/183))
|
||||
|
||||
## [0.0.24](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.24) (2025-08-24)
|
||||
* iOS: new tool for long press ([#143](https://github.com/mobile-next/mobile-mcp/pull/143))
|
||||
* Android: new tool for long press ([#143](https://github.com/mobile-next/mobile-mcp/pull/143))
|
||||
* Android: fixed screenshot from devices with multiple devices (foldables) again ([#171](https://github.com/mobile-next/mobile-mcp/pull/171))
|
||||
|
||||
## [0.0.23](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.23) (2025-07-31)
|
||||
* Android: fixed a bug where devices with multiple screens (such as foldables) failed to take and save screenshot ([#159](https://github.com/mobile-next/mobile-mcp/pull/159))
|
||||
|
||||
## [0.0.22](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.22) (2025-07-17)
|
||||
* iOS: fixed detection of go-ios installation ([#132](https://github.com/mobile-next/mobile-mcp/pull/132) by [@codeaholicguy](https://github.com/codeaholicguy)
|
||||
|
||||
## [0.0.21](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.21) (2025-06-27)
|
||||
* Server: use node: prefixed modules (like node:fs) ([449c498](https://github.com/mobile-next/mobile-mcp/commit/449c498e6e9a3e68aab55ea82f15c296171fc05e))
|
||||
* iOS: automatically start WebDriverAgent on simulator if already installed ([#126](https://github.com/mobile-next/mobile-mcp/pull/126))
|
||||
* Android: fixed detection of com.mobilenext.devicekit when running mcp on windows ([c11c642](https://github.com/mobile-next/mobile-mcp/commit/c11c6427c71cb7cef6ce87005047df977f6bea8a))
|
||||
|
||||
## [0.0.20](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.20) (2025-06-23)
|
||||
* Server: new tool `save_screenshot` which saves the screenshot to disk, to be used by other mcp servers ([#112](https://github.com/mobile-next/mobile-mcp/pull/112))
|
||||
* Server: new tool `use_default_device` which picks the only device that is connected, to speed up use ([#112](https://github.com/mobile-next/mobile-mcp/pull/112))
|
||||
* iOS: Use wda to grab screenshots for both real devices and simulators ([#115](https://github.com/mobile-next/mobile-mcp/pull/115))
|
||||
* Android: Support for utf-8 text in sendKeys, see [wiki page]() for getting started ([#117](https://github.com/mobile-next/mobile-mcp/pull/117))
|
||||
|
||||
## [0.0.19](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.19) (2025-06-16)
|
||||
* Server: Fixed support for Windsurf, where some tools caused a -32602 error ([#101](https://github.com/mobile-next/mobile-mcp/pull/101)) by [@amebahead](https://github.com/amebahead)
|
||||
* iOS: Support for swipe left and right. Support x,y,direction,duration for custom swipes ([#92](https://github.com/mobile-next/mobile-mcp/pull/92/)) by [@benlmyers](https://github.com/benlmyers)
|
||||
* Android: Support for swipe left and right. Support x,y,direction,duration for custom swipes ([#92](https://github.com/mobile-next/mobile-mcp/pull/92/)) by [@benlmyers](https://github.com/benlmyers)
|
||||
* Android: Fix for get elements on screen, where uiautomator prints out warnings before the actual xml ([#86](https://github.com/mobile-next/mobile-mcp/pull/86)) by [@wenerme](https://github.com/wenerme)
|
||||
|
||||
## [0.0.18](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.18) (2025-06-12)
|
||||
* Server: New support for SSE (Server-Sent-Events) transport, [see wiki for more information](https://github.com/mobile-next/mobile-mcp/wiki/Using-SSE-Transport) ([1b70d40](https://github.com/mobile-next/mobile-mcp/commit/1b70d403cd562a97a0723464f2b286f2fd6eee0a))
|
||||
* iOS: Using plutil for `simctl listapps` parsing, might probably fix some parsing issues ([cfba3aa](https://github.com/mobile-next/mobile-mcp/commit/cfba3aaac5beb66d08d1138fe42c924309ede303))
|
||||
* Other: We have a new Slack server, join us at http://mobilenexthq.com/join-slack
|
||||
|
||||
## [0.0.17](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.17) (2025-05-16)
|
||||
* iOS: Fixed parsing of simctl listapps where CFBundleDisplayName contains non-alphanumerical characters ([#59](https://github.com/mobile-next/mobile-mcp/issues/59)) ([bf19771d](https://github.com/mobile-next/mobile-mcp/pull/63/commits/bf19771dcd49444ba4841ec649e3a72a03b54c74))
|
||||
|
||||
## [0.0.16](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.16) (2025-05-10)
|
||||
* Server: Detect if there is a new version of the mcp and notify user ([14b015f](https://github.com/mobile-next/mobile-mcp/commit/14b015f29ab47aa1f3ae122a670a58eb7ef51fd8))
|
||||
* Server: Instead of returning x,y for tap, return [top,left,width,height] of elements on screen ([3169d2f](https://github.com/mobile-next/mobile-mcp/commit/3169d2f46f0c789e4c3188e137ac645d6f6eb27c))
|
||||
* iOS: Fixed coordinates location for iOS with retina display after image scaledown ([3169d2f](https://github.com/mobile-next/mobile-mcp/commit/3169d2f46f0c789e4c3188e137ac645d6f6eb27c))
|
||||
* iOS: Added detection of StaticText and Image in mobile_list_elements_on_screen ([debe75b](https://github.com/mobile-next/mobile-mcp/commit/debe75b5c8afcafcef8328201e9886bffdd1f128))
|
||||
|
||||
## [0.0.15](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.15) (2025-05-04)
|
||||
* Android: Fixed broken Android screenshots on Windows because of crlf ([#53](https://github.com/mobile-next/mobile-mcp/pull/53/files) by [@hanyuan97](https://github.com/hanyuan97))
|
||||
|
||||
## [0.0.14](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.14) (2025-05-02)
|
||||
* Server: Fix a bug where xcrun was required, now works on Linux as well ([7fddba7](https://github.com/mobile-next/mobile-mcp/commit/7fddba71af51690cfa76f81154f72c3120ab7f07))
|
||||
* Server: Removed dependency on sharp which was causing issues during installation, now ImageMagick is an optional dependency
|
||||
* Android: Try uiautomator-dump multiple times, in case ui hierarchy is not stable
|
||||
* Android: Return more information about elements on screen for better element detection
|
||||
* Android: Support for Android TV using dpad for navigation ([399443d](https://github.com/mobile-next/mobile-mcp/commit/399443d519284a54b670a1598689a73d178db2ec) by [@surajsau](https://github.com/surajsau))
|
||||
|
||||
## [0.0.13](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.13) (2025-04-17)
|
||||
* Server: Fix a bug where 'adb' is required to even work with iOS-only ([#30](https://github.com/mobile-next/mobile-mcp/issues/30)) ([867f662](https://github.com/mobile-next/mobile-mcp/pull/35/commits/867f662ac2edc68d542519bd72d1762d3dbca18d))
|
||||
* iOS: Support for orientation changes ([844dc0e](https://github.com/mobile-next/mobile-mcp/pull/28/commits/844dc0eb953169871b4cdd2a57735bf50abe721a))
|
||||
* Android: Support for orientation changes (eg 'change device to landscape') ([844dc0e](https://github.com/mobile-next/mobile-mcp/pull/28/commits/844dc0eb953169871b4cdd2a57735bf50abe721a))
|
||||
* Android: Improve element detection by using element name if label not found ([8e8aadf](https://github.com/mobile-next/mobile-mcp/pull/33/commits/8e8aadfd7f300ff5b7f0a7857a99d1103cd9e941) by [@tomoya0x00](https://github.com/tomoya0x00))
|
||||
|
||||
## [0.0.12](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.12) (2025-04-12)
|
||||
* Server: If hitting an error with tunnel, forward proxy, wda, descriptive error and link to documentation will be returned
|
||||
* iOS: go-ios path can be set in env GO_IOS_PATH
|
||||
* iOS: Support go-ios that was built locally (no version)
|
||||
* iOS: Return bundle display name for apps for better app launch
|
||||
* iOS: Fixed finding element coordinates on retina displays
|
||||
* iOS: Saving temporary screenshots onto temporary directory ([#19](https://github.com/mobile-next/mobile-mcp/issues/19))
|
||||
* iOS: Find elements better by removing off-screen and hidden elements
|
||||
* Android: Support for 'adb' under ANDROID_HOME
|
||||
* Android: Find elements better using accessibility hints and class names
|
||||
|
||||
## [0.0.11](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.11) (2025-04-06)
|
||||
* Server: Support submit after sending text (\n)
|
||||
* Server: Added support for multiple devices at the same time
|
||||
* iOS: Support for iOS physical devices using go-ios ([see wiki](https://github.com/mobile-next/mobile-mcp/wiki/Getting-Started-with-iOS-Physical-Device))
|
||||
* iOS: Added support for icons, search fields, and switches when getting elements on screen
|
||||
201
packages/mobile-mcp/LICENSE
Normal file
201
packages/mobile-mcp/LICENSE
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
137
packages/mobile-mcp/README.md
Normal file
137
packages/mobile-mcp/README.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# @qwen-code/mobile-mcp
|
||||
|
||||
Fork of [mobile-next/mobile-mcp](https://github.com/mobile-next/mobile-mcp) for [qwen-code](https://github.com/QwenLM/qwen-code), with opt-in relative coordinate support and additional Android tooling.
|
||||
|
||||
This package is an MCP server that enables LLM agents to interact with mobile devices (iOS and Android) through screenshots, accessibility elements, and coordinate-based touch actions. It supports simulators, emulators, and real devices.
|
||||
|
||||
## Upstream
|
||||
|
||||
Based on [mobile-next/mobile-mcp](https://github.com/mobile-next/mobile-mcp) v0.0.61 (`c5d7d27`). We track upstream via `git subtree` and will continue to sync updates. See [.vendored-patches.md](.vendored-patches.md) for local modifications and [scripts/sync-from-upstream.sh](scripts/sync-from-upstream.sh) for the sync mechanism.
|
||||
|
||||
## What we added
|
||||
|
||||
### 1. Opt-in 0-1000 relative coordinate mode
|
||||
|
||||
Mirrors the [cua-driver relative coordinate shim](../cua-driver/docs/relative-coordinates-design.md) for mobile. When enabled, all coordinate inputs/outputs are normalized to a 0-1000 scale, matching the Qwen VL model's `computer_use` / `mobile_use` coordinate convention.
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
| Variable | Values | Default | Description |
|
||||
| ----------------------------- | -------------------- | ------- | -------------------------------------------------------- |
|
||||
| `MOBILE_MCP_COORDINATE_SPACE` | `0` (off) / `1` (on) | `0` | Enable 0-1000 normalized coordinates |
|
||||
| `MOBILE_MCP_COORDINATE_SCALE` | Any positive integer | `1000` | Full scale value (use `999` for `mobile_use` convention) |
|
||||
|
||||
**How it works:**
|
||||
|
||||
- **Input denormalization**: Coordinate tools (`mobile_click_on_screen_at_coordinates`, `mobile_double_tap_on_screen`, `mobile_long_press_on_screen_at_coordinates`, `mobile_swipe_on_screen`) convert 0-1000 input to device pixels/points before execution.
|
||||
- **Output normalization**: `mobile_list_elements_on_screen` element coordinates are converted from pixels/points to 0-1000. `mobile_get_screen_size` reports 1000x1000.
|
||||
- **Description rewriting**: Tool descriptions change from "in pixels" to "in 0-1000 normalized coordinates" when enabled.
|
||||
- **Default off**: Zero behavior change when not configured. Fully backward compatible.
|
||||
|
||||
The normalization basis is `getScreenSize()` — logical points on iOS, physical pixels on Android. The shim runs entirely in `server.ts`; backend files (`android.ts`, `ios.ts`, etc.) are untouched.
|
||||
|
||||
### 2. Extended Android install options
|
||||
|
||||
`mobile_install_app` now supports Android-specific flags:
|
||||
|
||||
| Parameter | Flag | Description |
|
||||
| ------------------- | ---- | ------------------------------------- |
|
||||
| `replace` | `-r` | Replace existing app (default `true`) |
|
||||
| `grant_permissions` | `-g` | Grant all runtime permissions |
|
||||
| `allow_downgrade` | `-d` | Allow version code downgrade |
|
||||
| `allow_test` | `-t` | Allow test APKs |
|
||||
|
||||
iOS/simulator silently ignores these options.
|
||||
|
||||
### 3. Android-specific tools
|
||||
|
||||
| Tool | Description |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mobile_ui_dump` | Dump the full UI hierarchy as raw XML via `uiautomator`. Unlike `mobile_list_elements_on_screen` (filtered flat JSON), this returns the complete unfiltered XML tree with all node attributes. Use for debugging or when `mobile_list_elements_on_screen` misses elements. Supports `--compressed`. |
|
||||
| `mobile_adb_pull` | Pull a file from Android device to local filesystem |
|
||||
| `mobile_adb_push` | Push a local file to Android device (default restricted to `/sdcard/`, `force=true` to override) |
|
||||
|
||||
### 4. Telemetry disabled by default
|
||||
|
||||
Upstream PostHog telemetry is off by default in this fork. Set `MOBILEMCP_ENABLE_TELEMETRY=1` to re-enable.
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
### Device Management
|
||||
|
||||
- **`mobile_list_available_devices`** - List all available devices (simulators, emulators, and real devices)
|
||||
- **`mobile_get_screen_size`** - Get the screen size of the mobile device
|
||||
- **`mobile_get_orientation`** / **`mobile_set_orientation`** - Get/set screen orientation
|
||||
|
||||
### App Management
|
||||
|
||||
- **`mobile_list_apps`** - List all installed apps
|
||||
- **`mobile_launch_app`** / **`mobile_terminate_app`** - Launch/terminate apps
|
||||
- **`mobile_install_app`** - Install app with optional Android flags (-r/-g/-d/-t)
|
||||
- **`mobile_uninstall_app`** - Uninstall app
|
||||
|
||||
### Screen Interaction
|
||||
|
||||
- **`mobile_take_screenshot`** / **`mobile_save_screenshot`** - Capture screen
|
||||
- **`mobile_list_elements_on_screen`** - List UI elements with coordinates (cross-platform)
|
||||
- **`mobile_click_on_screen_at_coordinates`** - Tap at x,y
|
||||
- **`mobile_double_tap_on_screen`** - Double-tap at x,y
|
||||
- **`mobile_long_press_on_screen_at_coordinates`** - Long press at x,y
|
||||
- **`mobile_swipe_on_screen`** - Swipe in any direction
|
||||
|
||||
### Input & Navigation
|
||||
|
||||
- **`mobile_type_keys`** - Type text into focused element
|
||||
- **`mobile_press_button`** - Press device buttons (HOME, BACK, etc.)
|
||||
- **`mobile_open_url`** - Open URL in browser
|
||||
|
||||
### Recording & Debugging
|
||||
|
||||
- **`mobile_start_screen_recording`** / **`mobile_stop_screen_recording`** - Screen recording
|
||||
- **`mobile_list_crashes`** / **`mobile_get_crash`** - Crash reports
|
||||
|
||||
### Android Only
|
||||
|
||||
- **`mobile_ui_dump`** - Full UI hierarchy XML dump
|
||||
- **`mobile_adb_pull`** / **`mobile_adb_push`** - File transfer via ADB
|
||||
|
||||
## Usage
|
||||
|
||||
### MCP server configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mobile-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["@qwen-code/mobile-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With relative coordinates enabled:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mobile-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["@qwen-code/mobile-mcp"],
|
||||
"env": {
|
||||
"MOBILE_MCP_COORDINATE_SPACE": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Android**: [Android SDK Platform Tools](https://developer.android.com/tools/releases/platform-tools) (`adb` on PATH)
|
||||
- **iOS real devices**: [go-ios](https://github.com/danielpaulus/go-ios)
|
||||
- **iOS simulators**: Xcode with simulator runtimes + [mobilecli](https://github.com/mobile-next/mobilecli) (installed automatically via `mobilewright` dependency)
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 (same as upstream)
|
||||
161
packages/mobile-mcp/eslint.config.mjs
Normal file
161
packages/mobile-mcp/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
import importRules from "eslint-plugin-import";
|
||||
|
||||
const plugins = {
|
||||
"@stylistic": stylistic,
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
import: importRules,
|
||||
};
|
||||
|
||||
export const baseRules = {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
2,
|
||||
{args: "none", caughtErrors: "none"},
|
||||
],
|
||||
|
||||
/**
|
||||
* Enforced rules
|
||||
*/
|
||||
// syntax preferences
|
||||
"object-curly-spacing": ["error", "always"],
|
||||
quotes: [
|
||||
2,
|
||||
"double",
|
||||
{
|
||||
avoidEscape: true,
|
||||
allowTemplateLiterals: true,
|
||||
},
|
||||
],
|
||||
"jsx-quotes": [2, "prefer-single"],
|
||||
"no-extra-semi": 2,
|
||||
"@stylistic/semi": [2],
|
||||
"comma-style": [2, "last"],
|
||||
"wrap-iife": [2, "inside"],
|
||||
"spaced-comment": [
|
||||
2,
|
||||
"always",
|
||||
{
|
||||
markers: ["*"],
|
||||
},
|
||||
],
|
||||
eqeqeq: [2],
|
||||
"accessor-pairs": [
|
||||
2,
|
||||
{
|
||||
getWithoutSet: false,
|
||||
setWithoutGet: false,
|
||||
},
|
||||
],
|
||||
"brace-style": [2, "1tbs", {allowSingleLine: true}],
|
||||
curly: [2, "all"],
|
||||
"new-parens": 2,
|
||||
"arrow-parens": [2, "as-needed"],
|
||||
"prefer-const": 2,
|
||||
"quote-props": [2, "consistent"],
|
||||
"nonblock-statement-body-position": [2, "below"],
|
||||
|
||||
// anti-patterns
|
||||
"no-var": 2,
|
||||
"no-with": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-caller": 2,
|
||||
"no-implied-eval": 2,
|
||||
"no-labels": 2,
|
||||
"no-new-object": 2,
|
||||
"no-octal-escape": 2,
|
||||
"no-self-compare": 2,
|
||||
"no-shadow-restricted-names": 2,
|
||||
"no-cond-assign": 2,
|
||||
"no-debugger": 2,
|
||||
"no-dupe-keys": 2,
|
||||
"no-duplicate-case": 2,
|
||||
"no-empty-character-class": 2,
|
||||
"no-unreachable": 2,
|
||||
"no-unsafe-negation": 2,
|
||||
radix: 2,
|
||||
"valid-typeof": 2,
|
||||
"no-implicit-globals": [2],
|
||||
"no-unused-expressions": [
|
||||
2,
|
||||
{allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true},
|
||||
],
|
||||
"no-proto": 2,
|
||||
|
||||
// es2015 features
|
||||
"require-yield": 2,
|
||||
"template-curly-spacing": [2, "never"],
|
||||
|
||||
// spacing details
|
||||
"space-infix-ops": 2,
|
||||
"space-in-parens": [2, "never"],
|
||||
"array-bracket-spacing": [2, "never"],
|
||||
"comma-spacing": [2, {before: false, after: true}],
|
||||
"keyword-spacing": [2, "always"],
|
||||
"space-before-function-paren": [
|
||||
2,
|
||||
{
|
||||
anonymous: "never",
|
||||
named: "never",
|
||||
asyncArrow: "always",
|
||||
},
|
||||
],
|
||||
"no-whitespace-before-property": 2,
|
||||
"keyword-spacing": [
|
||||
2,
|
||||
{
|
||||
overrides: {
|
||||
if: {after: true},
|
||||
else: {after: true},
|
||||
for: {after: true},
|
||||
while: {after: true},
|
||||
do: {after: true},
|
||||
switch: {after: true},
|
||||
return: {after: true},
|
||||
},
|
||||
},
|
||||
],
|
||||
"arrow-spacing": [
|
||||
2,
|
||||
{
|
||||
after: true,
|
||||
before: true,
|
||||
},
|
||||
],
|
||||
"@stylistic/func-call-spacing": 2,
|
||||
"@stylistic/type-annotation-spacing": 2,
|
||||
|
||||
// file whitespace
|
||||
"no-multiple-empty-lines": [2, {max: 2, maxEOF: 0}],
|
||||
"no-mixed-spaces-and-tabs": 2,
|
||||
"no-trailing-spaces": 2,
|
||||
"linebreak-style": [process.platform === "win32" ? 0 : 2, "unix"],
|
||||
indent: [
|
||||
2,
|
||||
"tab",
|
||||
{SwitchCase: 1, CallExpression: {arguments: "first"}, MemberExpression: 1},
|
||||
],
|
||||
"key-spacing": [
|
||||
2,
|
||||
{
|
||||
beforeColon: false,
|
||||
},
|
||||
],
|
||||
"eol-last": 2,
|
||||
};
|
||||
|
||||
const languageOptions = {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 9,
|
||||
sourceType: "module",
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
plugins,
|
||||
languageOptions,
|
||||
rules: baseRules,
|
||||
},
|
||||
];
|
||||
6261
packages/mobile-mcp/package-lock.json
generated
Normal file
6261
packages/mobile-mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
68
packages/mobile-mcp/package.json
Normal file
68
packages/mobile-mcp/package.json
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"name": "@qwen-code/mobile-mcp",
|
||||
"version": "0.0.1",
|
||||
"description": "Mobile MCP with opt-in relative coordinate support",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git",
|
||||
"directory": "packages/mobile-mcp"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsc && chmod +x lib/index.js",
|
||||
"lint": "eslint .",
|
||||
"fixlint": "eslint . --fix",
|
||||
"test": "c8 playwright test",
|
||||
"watch": "tsc --watch",
|
||||
"clean": "rm -rf lib",
|
||||
"prepare": ""
|
||||
},
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"ajv": "^8.18.0",
|
||||
"commander": "14.0.0",
|
||||
"express": "5.1.0",
|
||||
"fast-xml-parser": "5.8.0",
|
||||
"qs": "^6.15.0",
|
||||
"zod": "^4.1.13",
|
||||
"zod-to-json-schema": "3.25.0",
|
||||
"mobilewright": "0.0.45"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/commander": "^2.12.0",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^22.13.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.28.0",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
"@typescript-eslint/utils": "^8.26.1",
|
||||
"c8": "^10.1.3",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin": "^1.0.1",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-notice": "^1.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"typescript": "5.8.2"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"bin": {
|
||||
"mcp-server-mobile": "lib/index.js"
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib"
|
||||
},
|
||||
"author": "",
|
||||
"bugs": {
|
||||
"url": "https://github.com/QwenLM/qwen-code/issues"
|
||||
},
|
||||
"homepage": "https://github.com/QwenLM/qwen-code/tree/main/packages/mobile-mcp#readme"
|
||||
}
|
||||
17
packages/mobile-mcp/playwright.config.ts
Normal file
17
packages/mobile-mcp/playwright.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// These are plain Node tests (no browser). Playwright is used purely as the
|
||||
// test runner, so no browser projects are configured.
|
||||
export default defineConfig({
|
||||
testDir: "./test",
|
||||
testMatch: "*.ts",
|
||||
|
||||
// Device tests (android/ios/iphone-simulator) mutate real device state and
|
||||
// must run serially, exactly as they did under mocha's single process.
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
|
||||
// Device operations include several multi-second sleeps; the 30s default is
|
||||
// too tight.
|
||||
timeout: 60_000,
|
||||
});
|
||||
48
packages/mobile-mcp/scripts/sync-from-upstream.sh
Executable file
48
packages/mobile-mcp/scripts/sync-from-upstream.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env bash
|
||||
# sync-from-upstream.sh — pull upstream mobile-mcp changes via git subtree.
|
||||
#
|
||||
# Usage:
|
||||
# packages/mobile-mcp/scripts/sync-from-upstream.sh [<branch-or-tag>]
|
||||
#
|
||||
# Default: pulls from 'main' branch of the mobile-mcp-upstream remote.
|
||||
#
|
||||
# Prerequisites:
|
||||
# git remote add mobile-mcp-upstream https://github.com/mobile-next/mobile-mcp.git
|
||||
#
|
||||
# After running: review the merge, resolve any conflicts in src/server.ts
|
||||
# (our coord-norm hooks and new tools), and src/coord-norm.ts (new file,
|
||||
# won't conflict). Then commit.
|
||||
#
|
||||
# If subtree pull becomes impractical (e.g. upstream history grows too large),
|
||||
# fall back to the cua-driver approach: generate a diff between two refs in a
|
||||
# local upstream clone and apply with `git apply --reject`.
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="${1:-main}"
|
||||
REMOTE="mobile-mcp-upstream"
|
||||
PREFIX="packages/mobile-mcp"
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Ensure remote exists
|
||||
if ! git remote | grep -q "^${REMOTE}$"; then
|
||||
echo "Adding remote ${REMOTE}..."
|
||||
git remote add "$REMOTE" https://github.com/mobile-next/mobile-mcp.git
|
||||
fi
|
||||
|
||||
echo "Fetching ${REMOTE}..."
|
||||
git fetch "$REMOTE"
|
||||
|
||||
echo "Pulling from ${REMOTE}/${BRANCH} into ${PREFIX}..."
|
||||
git subtree pull --prefix="$PREFIX" "$REMOTE" "$BRANCH" --squash \
|
||||
-m "chore(mobile-mcp): sync with upstream ${REMOTE}/${BRANCH}"
|
||||
|
||||
# Update the vendored-from marker
|
||||
UPSTREAM_SHA="$(git rev-parse "${REMOTE}/${BRANCH}")"
|
||||
echo "$UPSTREAM_SHA" > "${PREFIX}/.vendored-from"
|
||||
git add "${PREFIX}/.vendored-from"
|
||||
|
||||
echo ""
|
||||
echo "Done. Upstream synced to ${UPSTREAM_SHA}."
|
||||
echo "Review the merge, resolve any conflicts, then amend or commit."
|
||||
21
packages/mobile-mcp/server.json
Normal file
21
packages/mobile-mcp/server.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.qwenlm/mobile-mcp",
|
||||
"description": "MCP server for iOS and Android Mobile Development, Automation and Testing (qwen-code fork with relative coordinate support)",
|
||||
"repository": {
|
||||
"url": "https://github.com/QwenLM/qwen-code",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "{{VERSION}}",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "npm",
|
||||
"registryBaseUrl": "https://registry.npmjs.org",
|
||||
"identifier": "@qwen-code/mobile-mcp",
|
||||
"version": "{{VERSION}}",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
857
packages/mobile-mcp/src/android.ts
Normal file
857
packages/mobile-mcp/src/android.ts
Normal file
|
|
@ -0,0 +1,857 @@
|
|||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import * as xml from 'fast-xml-parser';
|
||||
|
||||
import {
|
||||
ActionableError,
|
||||
Button,
|
||||
InstalledApp,
|
||||
InstallOptions,
|
||||
Robot,
|
||||
ScreenElement,
|
||||
ScreenElementRect,
|
||||
ScreenSize,
|
||||
SwipeDirection,
|
||||
Orientation,
|
||||
} from './robot';
|
||||
import { validatePackageName, validateLocale } from './utils';
|
||||
|
||||
export interface AndroidDevice {
|
||||
deviceId: string;
|
||||
deviceType: 'tv' | 'mobile';
|
||||
}
|
||||
|
||||
interface UiAutomatorXmlNode {
|
||||
node: UiAutomatorXmlNode[];
|
||||
class?: string;
|
||||
text?: string;
|
||||
bounds?: string;
|
||||
hint?: string;
|
||||
focused?: string;
|
||||
checkable?: string;
|
||||
'content-desc'?: string;
|
||||
'resource-id'?: string;
|
||||
}
|
||||
|
||||
interface UiAutomatorXml {
|
||||
hierarchy: {
|
||||
node: UiAutomatorXmlNode;
|
||||
};
|
||||
}
|
||||
|
||||
const getAdbPath = (): string => {
|
||||
const exeName = process.env.platform === 'win32' ? 'adb.exe' : 'adb';
|
||||
if (process.env.ANDROID_HOME) {
|
||||
return path.join(process.env.ANDROID_HOME, 'platform-tools', exeName);
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
||||
const windowsAdbPath = path.join(
|
||||
process.env.LOCALAPPDATA,
|
||||
'Android',
|
||||
'Sdk',
|
||||
'platform-tools',
|
||||
'adb.exe',
|
||||
);
|
||||
if (existsSync(windowsAdbPath)) {
|
||||
return windowsAdbPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin' && process.env.HOME) {
|
||||
const defaultAndroidSdk = path.join(
|
||||
process.env.HOME,
|
||||
'Library',
|
||||
'Android',
|
||||
'sdk',
|
||||
'platform-tools',
|
||||
'adb',
|
||||
);
|
||||
if (existsSync(defaultAndroidSdk)) {
|
||||
return defaultAndroidSdk;
|
||||
}
|
||||
}
|
||||
|
||||
// fallthrough, hope for the best
|
||||
return exeName;
|
||||
};
|
||||
|
||||
const BUTTON_MAP: Record<Button, string> = {
|
||||
BACK: 'KEYCODE_BACK',
|
||||
HOME: 'KEYCODE_HOME',
|
||||
VOLUME_UP: 'KEYCODE_VOLUME_UP',
|
||||
VOLUME_DOWN: 'KEYCODE_VOLUME_DOWN',
|
||||
ENTER: 'KEYCODE_ENTER',
|
||||
DPAD_CENTER: 'KEYCODE_DPAD_CENTER',
|
||||
DPAD_UP: 'KEYCODE_DPAD_UP',
|
||||
DPAD_DOWN: 'KEYCODE_DPAD_DOWN',
|
||||
DPAD_LEFT: 'KEYCODE_DPAD_LEFT',
|
||||
DPAD_RIGHT: 'KEYCODE_DPAD_RIGHT',
|
||||
};
|
||||
|
||||
const TIMEOUT = 30000;
|
||||
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;
|
||||
|
||||
type AndroidDeviceType = 'tv' | 'mobile';
|
||||
|
||||
export class AndroidRobot implements Robot {
|
||||
public constructor(private deviceId: string) {}
|
||||
|
||||
public adb(...args: string[]): Buffer {
|
||||
return execFileSync(getAdbPath(), ['-s', this.deviceId, ...args], {
|
||||
maxBuffer: MAX_BUFFER_SIZE,
|
||||
timeout: TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
public silentAdb(...args: string[]): Buffer {
|
||||
return execFileSync(getAdbPath(), ['-s', this.deviceId, ...args], {
|
||||
maxBuffer: MAX_BUFFER_SIZE,
|
||||
timeout: TIMEOUT,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
public getSystemFeatures(): string[] {
|
||||
return this.adb('shell', 'pm', 'list', 'features')
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith('feature:'))
|
||||
.map((line) => line.substring('feature:'.length));
|
||||
}
|
||||
|
||||
public async getScreenSize(): Promise<ScreenSize> {
|
||||
const screenSize = this.adb('shell', 'wm', 'size')
|
||||
.toString()
|
||||
.split(' ')
|
||||
.pop();
|
||||
|
||||
if (!screenSize) {
|
||||
throw new Error('Failed to get screen size');
|
||||
}
|
||||
|
||||
const scale = 1;
|
||||
const [width, height] = screenSize.split('x').map(Number);
|
||||
return { width, height, scale };
|
||||
}
|
||||
|
||||
public async listApps(): Promise<InstalledApp[]> {
|
||||
// only apps that have a launcher activity are returned
|
||||
return this.adb(
|
||||
'shell',
|
||||
'cmd',
|
||||
'package',
|
||||
'query-activities',
|
||||
'-a',
|
||||
'android.intent.action.MAIN',
|
||||
'-c',
|
||||
'android.intent.category.LAUNCHER',
|
||||
)
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith('packageName='))
|
||||
.map((line) => line.substring('packageName='.length))
|
||||
.filter((value, index, self) => self.indexOf(value) === index)
|
||||
.map((packageName) => ({
|
||||
packageName,
|
||||
appName: packageName,
|
||||
}));
|
||||
}
|
||||
|
||||
private async listPackages(): Promise<string[]> {
|
||||
return this.adb('shell', 'pm', 'list', 'packages')
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith('package:'))
|
||||
.map((line) => line.substring('package:'.length));
|
||||
}
|
||||
|
||||
public async launchApp(packageName: string, locale?: string): Promise<void> {
|
||||
validatePackageName(packageName);
|
||||
|
||||
if (locale) {
|
||||
validateLocale(locale);
|
||||
try {
|
||||
this.silentAdb(
|
||||
'shell',
|
||||
'cmd',
|
||||
'locale',
|
||||
'set-app-locales',
|
||||
packageName,
|
||||
'--locales',
|
||||
locale,
|
||||
);
|
||||
} catch (error) {
|
||||
// set-app-locales requires Android 13+ (API 33), silently ignore on older versions
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.silentAdb(
|
||||
'shell',
|
||||
'monkey',
|
||||
'-p',
|
||||
packageName,
|
||||
'-c',
|
||||
'android.intent.category.LAUNCHER',
|
||||
'1',
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ActionableError(
|
||||
`Failed launching app with package name "${packageName}", please make sure it exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async listRunningProcesses(): Promise<string[]> {
|
||||
return this.adb('shell', 'ps', '-e')
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith('u')) // non-system processes
|
||||
.map((line) => line.split(/\s+/)[8]); // get process name
|
||||
}
|
||||
|
||||
public async swipe(direction: SwipeDirection): Promise<void> {
|
||||
const screenSize = await this.getScreenSize();
|
||||
const centerX = screenSize.width >> 1;
|
||||
|
||||
let x0: number, y0: number, x1: number, y1: number;
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
x0 = x1 = centerX;
|
||||
y0 = Math.floor(screenSize.height * 0.8);
|
||||
y1 = Math.floor(screenSize.height * 0.2);
|
||||
break;
|
||||
case 'down':
|
||||
x0 = x1 = centerX;
|
||||
y0 = Math.floor(screenSize.height * 0.2);
|
||||
y1 = Math.floor(screenSize.height * 0.8);
|
||||
break;
|
||||
case 'left':
|
||||
x0 = Math.floor(screenSize.width * 0.8);
|
||||
x1 = Math.floor(screenSize.width * 0.2);
|
||||
y0 = y1 = Math.floor(screenSize.height * 0.5);
|
||||
break;
|
||||
case 'right':
|
||||
x0 = Math.floor(screenSize.width * 0.2);
|
||||
x1 = Math.floor(screenSize.width * 0.8);
|
||||
y0 = y1 = Math.floor(screenSize.height * 0.5);
|
||||
break;
|
||||
default:
|
||||
throw new ActionableError(
|
||||
`Swipe direction "${direction}" is not supported`,
|
||||
);
|
||||
}
|
||||
|
||||
this.adb(
|
||||
'shell',
|
||||
'input',
|
||||
'swipe',
|
||||
`${x0}`,
|
||||
`${y0}`,
|
||||
`${x1}`,
|
||||
`${y1}`,
|
||||
'1000',
|
||||
);
|
||||
}
|
||||
|
||||
public async swipeFromCoordinate(
|
||||
x: number,
|
||||
y: number,
|
||||
direction: SwipeDirection,
|
||||
distance?: number,
|
||||
): Promise<void> {
|
||||
const screenSize = await this.getScreenSize();
|
||||
|
||||
let x0: number, y0: number, x1: number, y1: number;
|
||||
|
||||
// Use provided distance or default to 30% of screen dimension
|
||||
const defaultDistanceY = Math.floor(screenSize.height * 0.3);
|
||||
const defaultDistanceX = Math.floor(screenSize.width * 0.3);
|
||||
const swipeDistanceY = distance || defaultDistanceY;
|
||||
const swipeDistanceX = distance || defaultDistanceX;
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
x0 = x1 = x;
|
||||
y0 = y;
|
||||
y1 = Math.max(0, y - swipeDistanceY);
|
||||
break;
|
||||
case 'down':
|
||||
x0 = x1 = x;
|
||||
y0 = y;
|
||||
y1 = Math.min(screenSize.height, y + swipeDistanceY);
|
||||
break;
|
||||
case 'left':
|
||||
x0 = x;
|
||||
x1 = Math.max(0, x - swipeDistanceX);
|
||||
y0 = y1 = y;
|
||||
break;
|
||||
case 'right':
|
||||
x0 = x;
|
||||
x1 = Math.min(screenSize.width, x + swipeDistanceX);
|
||||
y0 = y1 = y;
|
||||
break;
|
||||
default:
|
||||
throw new ActionableError(
|
||||
`Swipe direction "${direction}" is not supported`,
|
||||
);
|
||||
}
|
||||
|
||||
this.adb(
|
||||
'shell',
|
||||
'input',
|
||||
'swipe',
|
||||
`${x0}`,
|
||||
`${y0}`,
|
||||
`${x1}`,
|
||||
`${y1}`,
|
||||
'1000',
|
||||
);
|
||||
}
|
||||
|
||||
private getDisplayCount(): number {
|
||||
return this.adb('shell', 'dumpsys', 'SurfaceFlinger', '--display-id')
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter((s) => s.startsWith('Display ')).length;
|
||||
}
|
||||
|
||||
private getFirstDisplayId(): string | null {
|
||||
try {
|
||||
// Try using cmd display get-displays (Android 11+)
|
||||
const displays = this.adb('shell', 'cmd', 'display', 'get-displays')
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter((s) => s.startsWith('Display id '))
|
||||
// filter for state ON even though get-displays only returns turned on displays
|
||||
.filter((s) => s.indexOf(', state ON,') >= 0)
|
||||
// another paranoia check
|
||||
.filter((s) => s.indexOf(', uniqueId ') >= 0);
|
||||
|
||||
if (displays.length > 0) {
|
||||
const m = displays[0].match(/uniqueId \"([^\"]+)\"/);
|
||||
if (m !== null) {
|
||||
let displayId = m[1];
|
||||
if (displayId.startsWith('local:')) {
|
||||
displayId = displayId.substring('local:'.length);
|
||||
}
|
||||
|
||||
return displayId;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// cmd display get-displays not available on this device
|
||||
}
|
||||
|
||||
// fallback: parse dumpsys display for display info (compatible with older Android versions)
|
||||
try {
|
||||
const dumpsys = this.adb('shell', 'dumpsys', 'display').toString();
|
||||
|
||||
// look for DisplayViewport entries with isActive=true and type=INTERNAL
|
||||
const viewportMatch = dumpsys.match(
|
||||
/DisplayViewport\{type=INTERNAL[^}]*isActive=true[^}]*uniqueId='([^']+)'/,
|
||||
);
|
||||
if (viewportMatch) {
|
||||
let uniqueId = viewportMatch[1];
|
||||
if (uniqueId.startsWith('local:')) {
|
||||
uniqueId = uniqueId.substring('local:'.length);
|
||||
}
|
||||
|
||||
return uniqueId;
|
||||
}
|
||||
|
||||
// fallback: look for active display with state ON
|
||||
const displayStateMatch = dumpsys.match(
|
||||
/Display Id=(\d+)[\s\S]*?Display State=ON/,
|
||||
);
|
||||
if (displayStateMatch) {
|
||||
return displayStateMatch[1];
|
||||
}
|
||||
} catch (error) {
|
||||
// dumpsys display also failed
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getScreenshot(): Promise<Buffer> {
|
||||
if (this.getDisplayCount() <= 1) {
|
||||
// backward compatibility for android 10 and below, and for single display devices
|
||||
return this.adb('exec-out', 'screencap', '-p');
|
||||
}
|
||||
|
||||
// find the first display that is turned on, and capture that one
|
||||
const displayId = this.getFirstDisplayId();
|
||||
if (displayId === null) {
|
||||
// no idea why, but we have displayCount >= 2, yet we failed to parse
|
||||
// let's go with screencap's defaults and hope for the best
|
||||
return this.adb('exec-out', 'screencap', '-p');
|
||||
}
|
||||
|
||||
return this.adb('exec-out', 'screencap', '-p', '-d', `${displayId}`);
|
||||
}
|
||||
|
||||
private collectElements(node: UiAutomatorXmlNode): ScreenElement[] {
|
||||
const elements: Array<ScreenElement> = [];
|
||||
|
||||
if (node.node) {
|
||||
if (Array.isArray(node.node)) {
|
||||
for (const childNode of node.node) {
|
||||
elements.push(...this.collectElements(childNode));
|
||||
}
|
||||
} else {
|
||||
elements.push(...this.collectElements(node.node));
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
node.text ||
|
||||
node['content-desc'] ||
|
||||
node.hint ||
|
||||
node['resource-id'] ||
|
||||
node.checkable === 'true'
|
||||
) {
|
||||
const element: ScreenElement = {
|
||||
type: node.class || 'text',
|
||||
text: node.text,
|
||||
label: node['content-desc'] || node.hint || '',
|
||||
rect: this.getScreenElementRect(node),
|
||||
};
|
||||
|
||||
if (node.focused === 'true') {
|
||||
// only provide it if it's true, otherwise don't confuse llm
|
||||
element.focused = true;
|
||||
}
|
||||
|
||||
const resourceId = node['resource-id'];
|
||||
if (resourceId !== null && resourceId !== '') {
|
||||
element.identifier = resourceId;
|
||||
}
|
||||
|
||||
if (element.rect.width > 0 && element.rect.height > 0) {
|
||||
elements.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
public async getElementsOnScreen(): Promise<ScreenElement[]> {
|
||||
const parsedXml = await this.getUiAutomatorXml();
|
||||
const hierarchy = parsedXml.hierarchy;
|
||||
const elements = this.collectElements(hierarchy.node);
|
||||
return elements;
|
||||
}
|
||||
|
||||
public async terminateApp(packageName: string): Promise<void> {
|
||||
validatePackageName(packageName);
|
||||
this.adb('shell', 'am', 'force-stop', packageName);
|
||||
}
|
||||
|
||||
public async installApp(
|
||||
path: string,
|
||||
options?: InstallOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const args: string[] = ['install'];
|
||||
const opts = options || {};
|
||||
if (opts.replace !== false) {
|
||||
args.push('-r');
|
||||
}
|
||||
if (opts.grantPermissions) {
|
||||
args.push('-g');
|
||||
}
|
||||
if (opts.allowDowngrade) {
|
||||
args.push('-d');
|
||||
}
|
||||
if (opts.allowTest) {
|
||||
args.push('-t');
|
||||
}
|
||||
args.push(path);
|
||||
this.adb(...args);
|
||||
} catch (error: any) {
|
||||
const stdout = error.stdout ? error.stdout.toString() : '';
|
||||
const stderr = error.stderr ? error.stderr.toString() : '';
|
||||
const output = (stdout + stderr).trim();
|
||||
throw new ActionableError(output || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async uninstallApp(bundleId: string): Promise<void> {
|
||||
try {
|
||||
this.adb('uninstall', bundleId);
|
||||
} catch (error: any) {
|
||||
const stdout = error.stdout ? error.stdout.toString() : '';
|
||||
const stderr = error.stderr ? error.stderr.toString() : '';
|
||||
const output = (stdout + stderr).trim();
|
||||
throw new ActionableError(output || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async openUrl(url: string): Promise<void> {
|
||||
this.adb(
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
this.escapeShellText(url),
|
||||
);
|
||||
}
|
||||
|
||||
private isAscii(text: string): boolean {
|
||||
return /^[\x00-\x7F]*$/.test(text);
|
||||
}
|
||||
|
||||
private escapeShellText(text: string): string {
|
||||
// escape all shell special characters that could be used for injection
|
||||
return text.replace(/[\\'"` \t\n\r|&;()<>{}[\]$*?]/g, '\\$&');
|
||||
}
|
||||
|
||||
private async isDeviceKitInstalled(): Promise<boolean> {
|
||||
const packages = await this.listPackages();
|
||||
return packages.includes('com.mobilenext.devicekit');
|
||||
}
|
||||
|
||||
public async sendKeys(text: string): Promise<void> {
|
||||
if (text === '') {
|
||||
// bailing early, so we don't run adb shell with empty string.
|
||||
// this happens when you prompt with a simple "submit".
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isAscii(text)) {
|
||||
// adb shell input only supports ascii characters. and
|
||||
// some of the keys have to be escaped.
|
||||
const _text = this.escapeShellText(text);
|
||||
this.adb('shell', 'input', 'text', _text);
|
||||
} else if (await this.isDeviceKitInstalled()) {
|
||||
// try sending over clipboard
|
||||
const base64 = Buffer.from(text).toString('base64');
|
||||
|
||||
// send clipboard over and immediately paste it
|
||||
this.adb(
|
||||
'shell',
|
||||
'am',
|
||||
'broadcast',
|
||||
'-a',
|
||||
'devicekit.clipboard.set',
|
||||
'-e',
|
||||
'encoding',
|
||||
'base64',
|
||||
'-e',
|
||||
'text',
|
||||
base64,
|
||||
'-n',
|
||||
'com.mobilenext.devicekit/.ClipboardBroadcastReceiver',
|
||||
);
|
||||
this.adb('shell', 'input', 'keyevent', 'KEYCODE_PASTE');
|
||||
|
||||
// clear clipboard when we're done
|
||||
this.adb(
|
||||
'shell',
|
||||
'am',
|
||||
'broadcast',
|
||||
'-a',
|
||||
'devicekit.clipboard.clear',
|
||||
'-n',
|
||||
'com.mobilenext.devicekit/.ClipboardBroadcastReceiver',
|
||||
);
|
||||
} else {
|
||||
throw new ActionableError(
|
||||
'Non-ASCII text is not supported on Android, please install mobilenext devicekit, see https://github.com/mobile-next/devicekit-android',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async pressButton(button: Button) {
|
||||
if (!BUTTON_MAP[button]) {
|
||||
throw new ActionableError(`Button "${button}" is not supported`);
|
||||
}
|
||||
|
||||
const mapped = BUTTON_MAP[button];
|
||||
this.adb('shell', 'input', 'keyevent', mapped);
|
||||
}
|
||||
|
||||
public async tap(x: number, y: number): Promise<void> {
|
||||
this.adb('shell', 'input', 'tap', `${x}`, `${y}`);
|
||||
}
|
||||
|
||||
public async longPress(
|
||||
x: number,
|
||||
y: number,
|
||||
duration: number,
|
||||
): Promise<void> {
|
||||
// a long press is a swipe with no movement and a long duration
|
||||
this.adb(
|
||||
'shell',
|
||||
'input',
|
||||
'swipe',
|
||||
`${x}`,
|
||||
`${y}`,
|
||||
`${x}`,
|
||||
`${y}`,
|
||||
`${duration}`,
|
||||
);
|
||||
}
|
||||
|
||||
public async doubleTap(x: number, y: number): Promise<void> {
|
||||
await this.tap(x, y);
|
||||
await new Promise((r) => setTimeout(r, 100)); // short delay
|
||||
await this.tap(x, y);
|
||||
}
|
||||
|
||||
public async setOrientation(orientation: Orientation): Promise<void> {
|
||||
const value = orientation === 'portrait' ? 0 : 1;
|
||||
|
||||
// disable auto-rotation prior to setting the orientation
|
||||
this.adb(
|
||||
'shell',
|
||||
'settings',
|
||||
'put',
|
||||
'system',
|
||||
'accelerometer_rotation',
|
||||
'0',
|
||||
);
|
||||
this.adb(
|
||||
'shell',
|
||||
'content',
|
||||
'insert',
|
||||
'--uri',
|
||||
'content://settings/system',
|
||||
'--bind',
|
||||
'name:s:user_rotation',
|
||||
'--bind',
|
||||
`value:i:${value}`,
|
||||
);
|
||||
}
|
||||
|
||||
public async getOrientation(): Promise<Orientation> {
|
||||
const rotation = this.adb(
|
||||
'shell',
|
||||
'settings',
|
||||
'get',
|
||||
'system',
|
||||
'user_rotation',
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
return rotation === '0' ? 'portrait' : 'landscape';
|
||||
}
|
||||
|
||||
public async dumpUiHierarchy(compressed: boolean = false): Promise<string> {
|
||||
for (let tries = 0; tries < 10; tries++) {
|
||||
const args = ['exec-out', 'uiautomator', 'dump'];
|
||||
if (compressed) {
|
||||
args.push('--compressed');
|
||||
}
|
||||
args.push('/dev/tty');
|
||||
const dump = this.adb(...args).toString();
|
||||
if (dump.includes('null root node returned by UiTestAutomationBridge')) {
|
||||
continue;
|
||||
}
|
||||
const xmlStart = dump.indexOf('<?xml');
|
||||
if (xmlStart === -1) {
|
||||
continue;
|
||||
}
|
||||
return dump.substring(xmlStart);
|
||||
}
|
||||
throw new ActionableError('Failed to get UIAutomator XML dump');
|
||||
}
|
||||
|
||||
public pullFile(remotePath: string, localPath: string): void {
|
||||
try {
|
||||
this.adb('pull', remotePath, localPath);
|
||||
} catch (error: any) {
|
||||
const stdout = error.stdout ? error.stdout.toString() : '';
|
||||
const stderr = error.stderr ? error.stderr.toString() : '';
|
||||
const output = (stdout + stderr).trim();
|
||||
throw new ActionableError(output || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
public pushFile(localPath: string, remotePath: string): void {
|
||||
try {
|
||||
this.adb('push', localPath, remotePath);
|
||||
} catch (error: any) {
|
||||
const stdout = error.stdout ? error.stdout.toString() : '';
|
||||
const stderr = error.stderr ? error.stderr.toString() : '';
|
||||
const output = (stdout + stderr).trim();
|
||||
throw new ActionableError(output || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async getUiAutomatorDump(): Promise<string> {
|
||||
for (let tries = 0; tries < 10; tries++) {
|
||||
const dump = this.adb(
|
||||
'exec-out',
|
||||
'uiautomator',
|
||||
'dump',
|
||||
'/dev/tty',
|
||||
).toString();
|
||||
// note: we're not catching other errors here. maybe we should check for <?xml
|
||||
if (dump.includes('null root node returned by UiTestAutomationBridge')) {
|
||||
// uncomment for debugging
|
||||
// const screenshot = await this.getScreenshot();
|
||||
// console.error("Failed to get UIAutomator XML. Here's a screenshot: " + screenshot.toString("base64"));
|
||||
continue;
|
||||
}
|
||||
|
||||
return dump.substring(dump.indexOf('<?xml'));
|
||||
}
|
||||
|
||||
throw new ActionableError('Failed to get UIAutomator XML');
|
||||
}
|
||||
|
||||
private async getUiAutomatorXml(): Promise<UiAutomatorXml> {
|
||||
const dump = await this.getUiAutomatorDump();
|
||||
const parser = new xml.XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '',
|
||||
});
|
||||
|
||||
return parser.parse(dump) as UiAutomatorXml;
|
||||
}
|
||||
|
||||
private getScreenElementRect(node: UiAutomatorXmlNode): ScreenElementRect {
|
||||
const bounds = String(node.bounds);
|
||||
|
||||
const [, left, top, right, bottom] =
|
||||
bounds.match(/^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$/)?.map(Number) || [];
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class AndroidDeviceManager {
|
||||
private getDeviceType(name: string): AndroidDeviceType {
|
||||
try {
|
||||
const device = new AndroidRobot(name);
|
||||
const features = device.getSystemFeatures();
|
||||
if (
|
||||
features.includes('android.software.leanback') ||
|
||||
features.includes('android.hardware.type.television')
|
||||
) {
|
||||
return 'tv';
|
||||
}
|
||||
return 'mobile';
|
||||
} catch (error) {
|
||||
// Fallback to mobile if we cannot determine device type
|
||||
return 'mobile';
|
||||
}
|
||||
}
|
||||
|
||||
private getDeviceVersion(deviceId: string): string {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
getAdbPath(),
|
||||
['-s', deviceId, 'shell', 'getprop', 'ro.build.version.release'],
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
return output;
|
||||
} catch (error) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private getDeviceName(deviceId: string): string {
|
||||
try {
|
||||
// Try getting AVD name first (for emulators)
|
||||
const avdName = execFileSync(
|
||||
getAdbPath(),
|
||||
['-s', deviceId, 'shell', 'getprop', 'ro.boot.qemu.avd_name'],
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
if (avdName !== '') {
|
||||
// Replace underscores with spaces (e.g., "Pixel_9_Pro" -> "Pixel 9 Pro")
|
||||
return avdName.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
// Fall back to product model
|
||||
const output = execFileSync(
|
||||
getAdbPath(),
|
||||
['-s', deviceId, 'shell', 'getprop', 'ro.product.model'],
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
return output;
|
||||
} catch (error) {
|
||||
return deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
public getConnectedDevices(): AndroidDevice[] {
|
||||
try {
|
||||
const names = execFileSync(getAdbPath(), ['devices'])
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== '')
|
||||
.filter((line) => !line.startsWith('List of devices attached'))
|
||||
.filter((line) => line.split('\t')[1]?.trim() === 'device') // Only include devices that are online and ready
|
||||
.map((line) => line.split('\t')[0]);
|
||||
|
||||
return names.map((name) => ({
|
||||
deviceId: name,
|
||||
deviceType: this.getDeviceType(name),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Could not execute adb command, maybe ANDROID_HOME is not set?',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public getConnectedDevicesWithDetails(): Array<
|
||||
AndroidDevice & { version: string; name: string }
|
||||
> {
|
||||
try {
|
||||
const names = execFileSync(getAdbPath(), ['devices'])
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== '')
|
||||
.filter((line) => !line.startsWith('List of devices attached'))
|
||||
.filter((line) => line.split('\t')[1]?.trim() === 'device') // Only include devices that are online and ready
|
||||
.map((line) => line.split('\t')[0]);
|
||||
|
||||
return names.map((deviceId) => ({
|
||||
deviceId,
|
||||
deviceType: this.getDeviceType(deviceId),
|
||||
version: this.getDeviceVersion(deviceId),
|
||||
name: this.getDeviceName(deviceId),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Could not execute adb command, maybe ANDROID_HOME is not set?',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
186
packages/mobile-mcp/src/coord-norm.ts
Normal file
186
packages/mobile-mcp/src/coord-norm.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// coord-norm.ts — Opt-in 0–1000 relative-coordinate shim for mobile-mcp.
|
||||
//
|
||||
// Mirrors packages/cua-driver's coord_norm.rs design. Default off = pixel
|
||||
// passthrough (zero behavior change). When on, input coordinates are
|
||||
// denormalized from 0–scale to device pixels/points before reaching the
|
||||
// backend, and output coordinates are normalized back.
|
||||
//
|
||||
// Env vars:
|
||||
// MOBILE_MCP_COORDINATE_SPACE "0" (default, off) | "1" (on)
|
||||
// MOBILE_MCP_COORDINATE_SCALE default 1000
|
||||
|
||||
// ── Global config (read once at module load) ─────────────────────────────────
|
||||
|
||||
export function isNormalized(): boolean {
|
||||
return process.env.MOBILE_MCP_COORDINATE_SPACE === '1';
|
||||
}
|
||||
|
||||
export function coordinateScale(): number {
|
||||
const raw = parseInt(process.env.MOBILE_MCP_COORDINATE_SCALE || '1000', 10);
|
||||
return isNaN(raw) || raw <= 0 ? 1000 : raw;
|
||||
}
|
||||
|
||||
// ── Scalar conversion ────────────────────────────────────────────────────────
|
||||
|
||||
export function normToPx(norm: number, dim: number, scale: number): number {
|
||||
return Math.round((norm / scale) * dim);
|
||||
}
|
||||
|
||||
export function pxToNorm(px: number, dim: number, scale: number): number {
|
||||
if (dim === 0) return 0;
|
||||
return Math.round((px / dim) * scale);
|
||||
}
|
||||
|
||||
// ── Per-tool coordinate field mapping ────────────────────────────────────────
|
||||
|
||||
interface CoordField {
|
||||
field: string;
|
||||
isX: boolean; // true = scale by width, false = scale by height
|
||||
}
|
||||
|
||||
const INPUT_COORD_FIELDS: Record<string, CoordField[]> = {
|
||||
mobile_click_on_screen_at_coordinates: [
|
||||
{ field: 'x', isX: true },
|
||||
{ field: 'y', isX: false },
|
||||
],
|
||||
mobile_double_tap_on_screen: [
|
||||
{ field: 'x', isX: true },
|
||||
{ field: 'y', isX: false },
|
||||
],
|
||||
mobile_long_press_on_screen_at_coordinates: [
|
||||
{ field: 'x', isX: true },
|
||||
{ field: 'y', isX: false },
|
||||
],
|
||||
mobile_swipe_on_screen: [
|
||||
{ field: 'x', isX: true },
|
||||
{ field: 'y', isX: false },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Per-device screen size cache ─────────────────────────────────────────────
|
||||
|
||||
const screenSizeCache = new Map<string, { width: number; height: number }>();
|
||||
|
||||
export function cacheScreenSize(
|
||||
deviceId: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
screenSizeCache.set(deviceId, { width, height });
|
||||
}
|
||||
|
||||
export function invalidateScreenSize(deviceId: string): void {
|
||||
screenSizeCache.delete(deviceId);
|
||||
}
|
||||
|
||||
export function getCachedScreenSize(
|
||||
deviceId: string,
|
||||
): { width: number; height: number } | undefined {
|
||||
return screenSizeCache.get(deviceId);
|
||||
}
|
||||
|
||||
// ── Input: denormalize 0–scale → pixels/points ──────────────────────────────
|
||||
|
||||
export function denormalizeArgs(
|
||||
toolName: string,
|
||||
args: Record<string, any>,
|
||||
screenWidth: number,
|
||||
screenHeight: number,
|
||||
): void {
|
||||
const scale = coordinateScale();
|
||||
const fields = INPUT_COORD_FIELDS[toolName];
|
||||
if (!fields) return;
|
||||
|
||||
for (const { field, isX } of fields) {
|
||||
if (typeof args[field] === 'number') {
|
||||
const dim = isX ? screenWidth : screenHeight;
|
||||
args[field] = normToPx(args[field], dim, scale);
|
||||
}
|
||||
}
|
||||
|
||||
// swipe distance: scale by the axis matching the swipe direction
|
||||
if (
|
||||
toolName === 'mobile_swipe_on_screen' &&
|
||||
typeof args.distance === 'number'
|
||||
) {
|
||||
const dir = args.direction;
|
||||
const dim = dir === 'up' || dir === 'down' ? screenHeight : screenWidth;
|
||||
args.distance = normToPx(args.distance, dim, scale);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output: normalize element coordinates and screen size ────────────────────
|
||||
|
||||
export function normalizeElementResult(
|
||||
toolName: string,
|
||||
response: string,
|
||||
screenWidth: number,
|
||||
screenHeight: number,
|
||||
): string {
|
||||
if (toolName !== 'mobile_list_elements_on_screen') return response;
|
||||
|
||||
const scale = coordinateScale();
|
||||
const prefix = 'Found these elements on screen: ';
|
||||
if (!response.startsWith(prefix)) return response;
|
||||
|
||||
try {
|
||||
const elements = JSON.parse(response.substring(prefix.length));
|
||||
for (const el of elements) {
|
||||
if (el.coordinates) {
|
||||
el.coordinates.x = pxToNorm(el.coordinates.x, screenWidth, scale);
|
||||
el.coordinates.y = pxToNorm(el.coordinates.y, screenHeight, scale);
|
||||
el.coordinates.width = pxToNorm(
|
||||
el.coordinates.width,
|
||||
screenWidth,
|
||||
scale,
|
||||
);
|
||||
el.coordinates.height = pxToNorm(
|
||||
el.coordinates.height,
|
||||
screenHeight,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
return prefix + JSON.stringify(elements);
|
||||
} catch (err) {
|
||||
console.error('[coord-norm] Failed to normalize element coordinates:', err);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeScreenSizeResult(
|
||||
toolName: string,
|
||||
response: string,
|
||||
): string {
|
||||
if (toolName !== 'mobile_get_screen_size') return response;
|
||||
const scale = coordinateScale();
|
||||
return `Screen size is ${scale}x${scale} (normalized 0-${scale} coordinate space)`;
|
||||
}
|
||||
|
||||
// ── Ingest: extract screen size from get_screen_size response ────────────────
|
||||
|
||||
export function ingestScreenSizeFromResult(
|
||||
deviceId: string,
|
||||
response: string,
|
||||
): void {
|
||||
// Response format: "Screen size is WIDTHxHEIGHT pixels"
|
||||
const match = response.match(/Screen size is (\d+)x(\d+)/);
|
||||
if (match) {
|
||||
cacheScreenSize(deviceId, parseInt(match[1], 10), parseInt(match[2], 10));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Description rewriting ────────────────────────────────────────────────────
|
||||
|
||||
export function rewriteDescription(description: string): string {
|
||||
const scale = coordinateScale();
|
||||
return description.replace(
|
||||
/\bin pixels\b/g,
|
||||
`in 0-${scale} normalized coordinates`,
|
||||
);
|
||||
}
|
||||
|
||||
export function coordParamDesc(baseDesc: string): string {
|
||||
if (!isNormalized()) return baseDesc;
|
||||
return rewriteDescription(baseDesc);
|
||||
}
|
||||
164
packages/mobile-mcp/src/image-utils.ts
Normal file
164
packages/mobile-mcp/src/image-utils.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { execFileSync, spawnSync } from "child_process";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { trace } from "./logger";
|
||||
|
||||
const DEFAULT_JPEG_QUALITY = 75;
|
||||
|
||||
export class ImageTransformer {
|
||||
|
||||
private newWidth: number = 0;
|
||||
private newFormat: "jpg" | "png" = "png";
|
||||
private jpegOptions: { quality: number } = { quality: DEFAULT_JPEG_QUALITY };
|
||||
|
||||
constructor(private buffer: Buffer) {}
|
||||
|
||||
public resize(width: number): ImageTransformer {
|
||||
this.newWidth = width;
|
||||
return this;
|
||||
}
|
||||
|
||||
public jpeg(options: { quality: number }): ImageTransformer {
|
||||
this.newFormat = "jpg";
|
||||
this.jpegOptions = options;
|
||||
return this;
|
||||
}
|
||||
|
||||
public png(): ImageTransformer {
|
||||
this.newFormat = "png";
|
||||
return this;
|
||||
}
|
||||
|
||||
public toBuffer(): Buffer {
|
||||
if (isSipsInstalled()) {
|
||||
try {
|
||||
return this.toBufferWithSips();
|
||||
} catch (error) {
|
||||
trace(`Sips failed, falling back to ImageMagick: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return this.toBufferWithImageMagick();
|
||||
} catch (error) {
|
||||
trace(`ImageMagick failed: ${error}`);
|
||||
throw new Error("Image scaling unavailable (requires Sips or ImageMagick).");
|
||||
}
|
||||
}
|
||||
|
||||
private qualityToSips(q: number): "low" | "normal" | "high" | "best" {
|
||||
if (q >= 90) {
|
||||
return "best";
|
||||
}
|
||||
|
||||
if (q >= 75) {
|
||||
return "high";
|
||||
}
|
||||
|
||||
if (q >= 50) {
|
||||
return "normal";
|
||||
}
|
||||
|
||||
return "low";
|
||||
}
|
||||
|
||||
private toBufferWithSips(): Buffer {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "image-"));
|
||||
const inputFile = path.join(tempDir, "input");
|
||||
const outputFile = path.join(tempDir, `output.${this.newFormat === "jpg" ? "jpg" : "png"}`);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(inputFile, this.buffer);
|
||||
|
||||
const args = ["-s", "format", this.newFormat === "jpg" ? "jpeg" : "png"];
|
||||
if (this.newFormat === "jpg") {
|
||||
args.push("-s", "formatOptions", this.qualityToSips(this.jpegOptions.quality));
|
||||
}
|
||||
|
||||
args.push("-Z", `${this.newWidth}`);
|
||||
args.push("--out", outputFile);
|
||||
args.push(inputFile);
|
||||
|
||||
trace(`Running sips command: /usr/bin/sips ${args.join(" ")}`);
|
||||
const proc = spawnSync("/usr/bin/sips", args, {
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
});
|
||||
|
||||
if (proc.status !== 0) {
|
||||
throw new Error(`Sips failed with status ${proc.status}`);
|
||||
}
|
||||
|
||||
const outputBuffer = fs.readFileSync(outputFile);
|
||||
trace("Sips returned buffer of size: " + outputBuffer.length);
|
||||
return outputBuffer;
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toBufferWithImageMagick(): Buffer {
|
||||
const magickArgs = ["-", "-resize", `${this.newWidth}x`, "-quality", `${this.jpegOptions.quality}`, `${this.newFormat}:-`];
|
||||
trace(`Running magick command: magick ${magickArgs.join(" ")}`);
|
||||
|
||||
const proc = spawnSync("magick", magickArgs, {
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
input: this.buffer
|
||||
});
|
||||
|
||||
return proc.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
export class Image {
|
||||
constructor(private buffer: Buffer) {}
|
||||
|
||||
public static fromBuffer(buffer: Buffer): Image {
|
||||
return new Image(buffer);
|
||||
}
|
||||
|
||||
public resize(width: number): ImageTransformer {
|
||||
return new ImageTransformer(this.buffer).resize(width);
|
||||
}
|
||||
|
||||
public jpeg(options: { quality: number }): ImageTransformer {
|
||||
return new ImageTransformer(this.buffer).jpeg(options);
|
||||
}
|
||||
}
|
||||
|
||||
const isDarwin = (): boolean => {
|
||||
return os.platform() === "darwin";
|
||||
};
|
||||
|
||||
export const isSipsInstalled = (): boolean => {
|
||||
if (!isDarwin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync("/usr/bin/sips", ["--version"]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isImageMagickInstalled = (): boolean => {
|
||||
try {
|
||||
return execFileSync("magick", ["--version"])
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter(line => line.includes("Version: ImageMagick"))
|
||||
.length > 0;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isScalingAvailable = (): boolean => {
|
||||
return isImageMagickInstalled() || isSipsInstalled();
|
||||
};
|
||||
132
packages/mobile-mcp/src/index.ts
Normal file
132
packages/mobile-mcp/src/index.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env node
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { createMcpServer, getAgentVersion } from "./server";
|
||||
import { error } from "./logger";
|
||||
import express from "express";
|
||||
import { program } from "commander";
|
||||
|
||||
const startSseServer = async (host: string, port: number) => {
|
||||
const app = express();
|
||||
const server = createMcpServer();
|
||||
|
||||
const authToken = process.env.MOBILEMCP_AUTH;
|
||||
if (!authToken) {
|
||||
error("WARNING: MOBILEMCP_AUTH is not set. The SSE server will accept unauthenticated connections. Set MOBILEMCP_AUTH to require Bearer token authentication.");
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
app.use((req, res, next) => {
|
||||
if (req.headers.authorization !== `Bearer ${authToken}`) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Block cross-origin requests — MCP clients are not browsers
|
||||
app.use((req, res, next) => {
|
||||
if (req.headers.origin) {
|
||||
res.status(403).json({ error: "Cross-origin requests are not allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
let transport: SSEServerTransport | null = null;
|
||||
|
||||
app.post("/mcp", (req, res) => {
|
||||
if (transport) {
|
||||
transport.handlePostMessage(req, res);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/mcp", (req, res) => {
|
||||
if (transport) {
|
||||
res.status(409).json({ error: "Another client is already connected. Disconnect the existing client first." });
|
||||
return;
|
||||
}
|
||||
|
||||
transport = new SSEServerTransport("/mcp", res);
|
||||
|
||||
transport.onclose = () => {
|
||||
transport = null;
|
||||
};
|
||||
|
||||
server.connect(transport);
|
||||
});
|
||||
|
||||
app.listen(port, host, () => {
|
||||
error(`mobile-mcp ${getAgentVersion()} sse server listening on http://${host}:${port}/mcp`);
|
||||
});
|
||||
};
|
||||
|
||||
const startStdioServer = async () => {
|
||||
try {
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
const server = createMcpServer();
|
||||
await server.connect(transport);
|
||||
|
||||
// Exit cleanly on termination signals so node flushes pending work
|
||||
// (including NODE_V8_COVERAGE output). Node's default SIGINT/SIGTERM
|
||||
// handling terminates the process without writing the coverage file,
|
||||
// which makes the `test:mcp` report come back all zeros.
|
||||
const shutdown = () => {
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
error("mobile-mcp server running on stdio");
|
||||
} catch (err: any) {
|
||||
console.error("Fatal error in main():", err);
|
||||
error("Fatal error in main(): " + JSON.stringify(err.stack));
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
program
|
||||
.version(getAgentVersion())
|
||||
.option("--listen <listen>", "Start SSE server on [host:]port")
|
||||
.option("--stdio", "Start stdio server (default)")
|
||||
.parse(process.argv);
|
||||
|
||||
const options = program.opts();
|
||||
|
||||
if (options.listen) {
|
||||
const listen = (options.listen as string).trim();
|
||||
const lastColon = listen.lastIndexOf(":");
|
||||
let host = "localhost";
|
||||
let rawPort: string;
|
||||
|
||||
if (lastColon > 0) {
|
||||
host = listen.substring(0, lastColon);
|
||||
rawPort = listen.substring(lastColon + 1);
|
||||
} else {
|
||||
rawPort = listen;
|
||||
}
|
||||
|
||||
const port = Number.parseInt(rawPort, 10);
|
||||
if (!host || !rawPort || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
error(`Invalid --listen value "${listen}". Expected [host:]port with port 1-65535.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await startSseServer(host, port);
|
||||
} else {
|
||||
await startStdioServer();
|
||||
}
|
||||
};
|
||||
|
||||
main().then();
|
||||
348
packages/mobile-mcp/src/ios.ts
Normal file
348
packages/mobile-mcp/src/ios.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import { Socket } from 'node:net';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import { WebDriverAgent } from './webdriver-agent';
|
||||
import {
|
||||
ActionableError,
|
||||
Button,
|
||||
InstalledApp,
|
||||
InstallOptions,
|
||||
Robot,
|
||||
ScreenSize,
|
||||
SwipeDirection,
|
||||
ScreenElement,
|
||||
Orientation,
|
||||
} from './robot';
|
||||
import { validatePackageName, validateLocale } from './utils';
|
||||
|
||||
const WDA_PORT = 8100;
|
||||
const IOS_TUNNEL_PORT = 60105;
|
||||
|
||||
interface ListCommandOutput {
|
||||
deviceList: string[];
|
||||
}
|
||||
|
||||
interface VersionCommandOutput {
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface InfoCommandOutput {
|
||||
DeviceClass: string;
|
||||
DeviceName: string;
|
||||
ProductName: string;
|
||||
ProductType: string;
|
||||
ProductVersion: string;
|
||||
PhoneNumber: string;
|
||||
TimeZone: string;
|
||||
}
|
||||
|
||||
export interface IosDevice {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
const getGoIosPath = (): string => {
|
||||
if (process.env.GO_IOS_PATH) {
|
||||
return process.env.GO_IOS_PATH;
|
||||
}
|
||||
|
||||
// fallback to go-ios in PATH via `npm install -g go-ios`
|
||||
return 'ios';
|
||||
};
|
||||
|
||||
export class IosRobot implements Robot {
|
||||
public constructor(private deviceId: string) {}
|
||||
|
||||
private isListeningOnPort(port: number): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Socket();
|
||||
client.connect(port, 'localhost', () => {
|
||||
client.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
client.on('error', (err: any) => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async isTunnelRunning(): Promise<boolean> {
|
||||
return await this.isListeningOnPort(IOS_TUNNEL_PORT);
|
||||
}
|
||||
|
||||
private async isWdaForwardRunning(): Promise<boolean> {
|
||||
return await this.isListeningOnPort(WDA_PORT);
|
||||
}
|
||||
|
||||
private async assertTunnelRunning(): Promise<void> {
|
||||
if (await this.isTunnelRequired()) {
|
||||
if (!(await this.isTunnelRunning())) {
|
||||
throw new ActionableError(
|
||||
'iOS tunnel is not running, please see https://github.com/mobile-next/mobile-mcp/wiki/',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async wda(): Promise<WebDriverAgent> {
|
||||
await this.assertTunnelRunning();
|
||||
|
||||
if (!(await this.isWdaForwardRunning())) {
|
||||
throw new ActionableError(
|
||||
'Port forwarding to WebDriverAgent is not running (tunnel okay), please see https://github.com/mobile-next/mobile-mcp/wiki/',
|
||||
);
|
||||
}
|
||||
|
||||
const wda = new WebDriverAgent('localhost', WDA_PORT);
|
||||
|
||||
if (!(await wda.isRunning())) {
|
||||
throw new ActionableError(
|
||||
'WebDriverAgent is not running on device (tunnel okay, port forwarding okay), please see https://github.com/mobile-next/mobile-mcp/wiki/',
|
||||
);
|
||||
}
|
||||
|
||||
return wda;
|
||||
}
|
||||
|
||||
private async ios(...args: string[]): Promise<string> {
|
||||
return execFileSync(
|
||||
getGoIosPath(),
|
||||
['--udid', this.deviceId, ...args],
|
||||
{},
|
||||
).toString();
|
||||
}
|
||||
|
||||
public async getIosVersion(): Promise<string> {
|
||||
const output = await this.ios('info');
|
||||
const json = JSON.parse(output);
|
||||
return json.ProductVersion;
|
||||
}
|
||||
|
||||
private async isTunnelRequired(): Promise<boolean> {
|
||||
const version = await this.getIosVersion();
|
||||
const args = version.split('.');
|
||||
return parseInt(args[0], 10) >= 17;
|
||||
}
|
||||
|
||||
public async getScreenSize(): Promise<ScreenSize> {
|
||||
const wda = await this.wda();
|
||||
return await wda.getScreenSize();
|
||||
}
|
||||
|
||||
public async swipe(direction: SwipeDirection): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
await wda.swipe(direction);
|
||||
}
|
||||
|
||||
public async swipeFromCoordinate(
|
||||
x: number,
|
||||
y: number,
|
||||
direction: SwipeDirection,
|
||||
distance?: number,
|
||||
): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
await wda.swipeFromCoordinate(x, y, direction, distance);
|
||||
}
|
||||
|
||||
public async listApps(): Promise<InstalledApp[]> {
|
||||
await this.assertTunnelRunning();
|
||||
|
||||
const output = await this.ios('apps', '--all', '--list');
|
||||
return output.split('\n').map((line) => {
|
||||
const [packageName, appName] = line.split(' ');
|
||||
return {
|
||||
packageName,
|
||||
appName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async launchApp(packageName: string, locale?: string): Promise<void> {
|
||||
validatePackageName(packageName);
|
||||
await this.assertTunnelRunning();
|
||||
const args = ['launch', packageName];
|
||||
if (locale) {
|
||||
validateLocale(locale);
|
||||
const locales = locale.split(',').map((l) => l.trim());
|
||||
args.push('-AppleLanguages', `(${locales.join(', ')})`);
|
||||
args.push('-AppleLocale', locales[0]);
|
||||
}
|
||||
|
||||
await this.ios(...args);
|
||||
}
|
||||
|
||||
public async terminateApp(packageName: string): Promise<void> {
|
||||
validatePackageName(packageName);
|
||||
await this.assertTunnelRunning();
|
||||
await this.ios('kill', packageName);
|
||||
}
|
||||
|
||||
public async installApp(
|
||||
path: string,
|
||||
_options?: InstallOptions,
|
||||
): Promise<void> {
|
||||
await this.assertTunnelRunning();
|
||||
try {
|
||||
await this.ios('install', '--path', path);
|
||||
} catch (error: any) {
|
||||
const stdout = error.stdout ? error.stdout.toString() : '';
|
||||
const stderr = error.stderr ? error.stderr.toString() : '';
|
||||
const output = (stdout + stderr).trim();
|
||||
throw new ActionableError(output || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async uninstallApp(bundleId: string): Promise<void> {
|
||||
await this.assertTunnelRunning();
|
||||
try {
|
||||
await this.ios('uninstall', '--bundleid', bundleId);
|
||||
} catch (error: any) {
|
||||
const stdout = error.stdout ? error.stdout.toString() : '';
|
||||
const stderr = error.stderr ? error.stderr.toString() : '';
|
||||
const output = (stdout + stderr).trim();
|
||||
throw new ActionableError(output || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async openUrl(url: string): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
await wda.openUrl(url);
|
||||
}
|
||||
|
||||
public async sendKeys(text: string): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
await wda.sendKeys(text);
|
||||
}
|
||||
|
||||
public async pressButton(button: Button): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
await wda.pressButton(button);
|
||||
}
|
||||
|
||||
public async tap(x: number, y: number): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
await wda.tap(x, y);
|
||||
}
|
||||
|
||||
public async doubleTap(x: number, y: number): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
await wda.doubleTap(x, y);
|
||||
}
|
||||
|
||||
public async longPress(
|
||||
x: number,
|
||||
y: number,
|
||||
duration: number,
|
||||
): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
await wda.longPress(x, y, duration);
|
||||
}
|
||||
|
||||
public async getElementsOnScreen(): Promise<ScreenElement[]> {
|
||||
const wda = await this.wda();
|
||||
return await wda.getElementsOnScreen();
|
||||
}
|
||||
|
||||
public async getScreenshot(): Promise<Buffer> {
|
||||
const wda = await this.wda();
|
||||
return await wda.getScreenshot();
|
||||
|
||||
/* alternative:
|
||||
await this.assertTunnelRunning();
|
||||
const tmpFilename = path.join(tmpdir(), `screenshot-${randomBytes(8).toString("hex")}.png`);
|
||||
await this.ios("screenshot", "--output", tmpFilename);
|
||||
const buffer = readFileSync(tmpFilename);
|
||||
unlinkSync(tmpFilename);
|
||||
return buffer;
|
||||
*/
|
||||
}
|
||||
|
||||
public async setOrientation(orientation: Orientation): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
await wda.setOrientation(orientation);
|
||||
}
|
||||
|
||||
public async getOrientation(): Promise<Orientation> {
|
||||
const wda = await this.wda();
|
||||
return await wda.getOrientation();
|
||||
}
|
||||
}
|
||||
|
||||
export class IosManager {
|
||||
public isGoIosInstalled(): boolean {
|
||||
try {
|
||||
const output = execFileSync(getGoIosPath(), ['version'], {
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
}).toString();
|
||||
const json: VersionCommandOutput = JSON.parse(output);
|
||||
return (
|
||||
json.version !== undefined &&
|
||||
(/^v?\d+\.\d+\.\d+/.test(json.version) ||
|
||||
json.version === 'local-build')
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public getDeviceName(deviceId: string): string {
|
||||
const output = execFileSync(getGoIosPath(), [
|
||||
'info',
|
||||
'--udid',
|
||||
deviceId,
|
||||
]).toString();
|
||||
const json: InfoCommandOutput = JSON.parse(output);
|
||||
return json.DeviceName;
|
||||
}
|
||||
|
||||
public getDeviceInfo(deviceId: string): InfoCommandOutput {
|
||||
const output = execFileSync(getGoIosPath(), [
|
||||
'info',
|
||||
'--udid',
|
||||
deviceId,
|
||||
]).toString();
|
||||
const json: InfoCommandOutput = JSON.parse(output);
|
||||
return json;
|
||||
}
|
||||
|
||||
public listDevices(): IosDevice[] {
|
||||
if (!this.isGoIosInstalled()) {
|
||||
console.error(
|
||||
'go-ios is not installed, no physical iOS devices can be detected',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const output = execFileSync(getGoIosPath(), ['list']).toString();
|
||||
const json: ListCommandOutput = JSON.parse(output);
|
||||
const devices = json.deviceList.map((device) => ({
|
||||
deviceId: device,
|
||||
deviceName: this.getDeviceName(device),
|
||||
}));
|
||||
|
||||
return devices;
|
||||
}
|
||||
|
||||
public listDevicesWithDetails(): Array<IosDevice & { version: string }> {
|
||||
if (!this.isGoIosInstalled()) {
|
||||
console.error(
|
||||
'go-ios is not installed, no physical iOS devices can be detected',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const output = execFileSync(getGoIosPath(), ['list']).toString();
|
||||
const json: ListCommandOutput = JSON.parse(output);
|
||||
const devices = json.deviceList.map((device) => {
|
||||
const info = this.getDeviceInfo(device);
|
||||
return {
|
||||
deviceId: device,
|
||||
deviceName: info.DeviceName,
|
||||
version: info.ProductVersion,
|
||||
};
|
||||
});
|
||||
|
||||
return devices;
|
||||
}
|
||||
}
|
||||
312
packages/mobile-mcp/src/iphone-simulator.ts
Normal file
312
packages/mobile-mcp/src/iphone-simulator.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, basename, extname } from 'node:path';
|
||||
|
||||
import { trace } from './logger';
|
||||
import { WebDriverAgent } from './webdriver-agent';
|
||||
import {
|
||||
ActionableError,
|
||||
Button,
|
||||
InstalledApp,
|
||||
InstallOptions,
|
||||
Robot,
|
||||
ScreenElement,
|
||||
ScreenSize,
|
||||
SwipeDirection,
|
||||
Orientation,
|
||||
} from './robot';
|
||||
import { validatePackageName, validateLocale } from './utils';
|
||||
|
||||
export interface Simulator {
|
||||
name: string;
|
||||
uuid: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface AppInfo {
|
||||
ApplicationType: string;
|
||||
Bundle: string;
|
||||
CFBundleDisplayName: string;
|
||||
CFBundleExecutable: string;
|
||||
CFBundleIdentifier: string;
|
||||
CFBundleName: string;
|
||||
CFBundleVersion: string;
|
||||
DataContainer: string;
|
||||
Path: string;
|
||||
}
|
||||
|
||||
const TIMEOUT = 30000;
|
||||
const WDA_PORT = 8100;
|
||||
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;
|
||||
|
||||
export class Simctl implements Robot {
|
||||
constructor(private readonly simulatorUuid: string) {}
|
||||
|
||||
private async isWdaInstalled(): Promise<boolean> {
|
||||
const apps = await this.listApps();
|
||||
return apps
|
||||
.map((app) => app.packageName)
|
||||
.includes('com.facebook.WebDriverAgentRunner.xctrunner');
|
||||
}
|
||||
|
||||
private async startWda(): Promise<void> {
|
||||
if (!(await this.isWdaInstalled())) {
|
||||
// wda is not even installed, won't attempt to start it
|
||||
return;
|
||||
}
|
||||
|
||||
trace('Starting WebDriverAgent');
|
||||
const webdriverPackageName = 'com.facebook.WebDriverAgentRunner.xctrunner';
|
||||
this.simctl('launch', this.simulatorUuid, webdriverPackageName);
|
||||
|
||||
// now we wait for wda to have a successful status
|
||||
const wda = new WebDriverAgent('localhost', WDA_PORT);
|
||||
|
||||
// wait up to 10 seconds for wda to start
|
||||
const timeout = +new Date() + 10 * 1000;
|
||||
while (+new Date() < timeout) {
|
||||
// cross fingers and see if wda is already running
|
||||
if (await wda.isRunning()) {
|
||||
trace('WebDriverAgent is now running');
|
||||
return;
|
||||
}
|
||||
|
||||
// wait 100ms before trying again
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
trace('Could not start WebDriverAgent in time, giving up');
|
||||
}
|
||||
|
||||
private async wda(): Promise<WebDriverAgent> {
|
||||
const wda = new WebDriverAgent('localhost', WDA_PORT);
|
||||
|
||||
if (!(await wda.isRunning())) {
|
||||
await this.startWda();
|
||||
if (!(await wda.isRunning())) {
|
||||
throw new ActionableError(
|
||||
'WebDriverAgent is not running on simulator, please see https://github.com/mobile-next/mobile-mcp/wiki/',
|
||||
);
|
||||
}
|
||||
|
||||
// was successfully started
|
||||
}
|
||||
|
||||
return wda;
|
||||
}
|
||||
|
||||
private simctl(...args: string[]): Buffer {
|
||||
return execFileSync('xcrun', ['simctl', ...args], {
|
||||
timeout: TIMEOUT,
|
||||
maxBuffer: MAX_BUFFER_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
public async getScreenshot(): Promise<Buffer> {
|
||||
const wda = await this.wda();
|
||||
return await wda.getScreenshot();
|
||||
// alternative: return this.simctl("io", this.simulatorUuid, "screenshot", "-");
|
||||
}
|
||||
|
||||
public async openUrl(url: string) {
|
||||
const wda = await this.wda();
|
||||
await wda.openUrl(url);
|
||||
// alternative: this.simctl("openurl", this.simulatorUuid, url);
|
||||
}
|
||||
|
||||
public async launchApp(packageName: string, locale?: string) {
|
||||
validatePackageName(packageName);
|
||||
const args = ['launch', this.simulatorUuid, packageName];
|
||||
if (locale) {
|
||||
validateLocale(locale);
|
||||
const locales = locale.split(',').map((l) => l.trim());
|
||||
args.push('-AppleLanguages', `(${locales.join(', ')})`);
|
||||
args.push('-AppleLocale', locales[0]);
|
||||
}
|
||||
|
||||
this.simctl(...args);
|
||||
}
|
||||
|
||||
public async terminateApp(packageName: string) {
|
||||
validatePackageName(packageName);
|
||||
this.simctl('terminate', this.simulatorUuid, packageName);
|
||||
}
|
||||
|
||||
private findAppBundle(dir: string): string | null {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.endsWith('.app')) {
|
||||
return join(dir, entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private validateZipPaths(zipPath: string): void {
|
||||
const output = execFileSync('/usr/bin/zipinfo', ['-1', zipPath], {
|
||||
timeout: TIMEOUT,
|
||||
maxBuffer: MAX_BUFFER_SIZE,
|
||||
}).toString();
|
||||
|
||||
const invalidPath = output
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s)
|
||||
.find((s) => s.startsWith('/') || s.includes('..'));
|
||||
|
||||
if (invalidPath) {
|
||||
throw new ActionableError(
|
||||
`Security violation: File path '${invalidPath}' contains invalid characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async installApp(
|
||||
path: string,
|
||||
_options?: InstallOptions,
|
||||
): Promise<void> {
|
||||
let tempDir: string | null = null;
|
||||
let installPath = path;
|
||||
|
||||
try {
|
||||
// zip files need to be extracted prior to installation
|
||||
if (extname(path).toLowerCase() === '.zip') {
|
||||
trace(`Detected .zip file, validating contents`);
|
||||
|
||||
// before extracting, let's make sure there's no zip-slip bombs here
|
||||
this.validateZipPaths(path);
|
||||
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'ios-app-'));
|
||||
|
||||
try {
|
||||
execFileSync('unzip', ['-q', path, '-d', tempDir], {
|
||||
timeout: TIMEOUT,
|
||||
});
|
||||
} catch (error: any) {
|
||||
throw new ActionableError(`Failed to unzip file: ${error.message}`);
|
||||
}
|
||||
|
||||
const appBundle = this.findAppBundle(tempDir);
|
||||
if (!appBundle) {
|
||||
throw new ActionableError(
|
||||
'No .app bundle found in the .zip file, please visit wiki at https://github.com/mobile-next/mobile-mcp/wiki for assistance.',
|
||||
);
|
||||
}
|
||||
|
||||
installPath = appBundle;
|
||||
trace(`Found .app bundle at: ${basename(appBundle)}`);
|
||||
}
|
||||
|
||||
// continue with installation
|
||||
this.simctl('install', this.simulatorUuid, installPath);
|
||||
} catch (error: any) {
|
||||
const stdout = error.stdout ? error.stdout.toString() : '';
|
||||
const stderr = error.stderr ? error.stderr.toString() : '';
|
||||
const output = (stdout + stderr).trim();
|
||||
throw new ActionableError(output || error.message);
|
||||
} finally {
|
||||
// Clean up temporary directory if it was created
|
||||
if (tempDir) {
|
||||
try {
|
||||
trace(`Cleaning up temporary directory`);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
trace(
|
||||
`Warning: Failed to cleanup temporary directory: ${cleanupError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async uninstallApp(bundleId: string): Promise<void> {
|
||||
try {
|
||||
this.simctl('uninstall', this.simulatorUuid, bundleId);
|
||||
} catch (error: any) {
|
||||
const stdout = error.stdout ? error.stdout.toString() : '';
|
||||
const stderr = error.stderr ? error.stderr.toString() : '';
|
||||
const output = (stdout + stderr).trim();
|
||||
throw new ActionableError(output || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async listApps(): Promise<InstalledApp[]> {
|
||||
const text = this.simctl('listapps', this.simulatorUuid).toString();
|
||||
const result = execFileSync(
|
||||
'plutil',
|
||||
['-convert', 'json', '-o', '-', '-r', '-'],
|
||||
{
|
||||
input: text,
|
||||
},
|
||||
);
|
||||
|
||||
const output = JSON.parse(result.toString()) as Record<string, AppInfo>;
|
||||
return Object.values(output).map((app) => ({
|
||||
packageName: app.CFBundleIdentifier,
|
||||
appName: app.CFBundleDisplayName,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getScreenSize(): Promise<ScreenSize> {
|
||||
const wda = await this.wda();
|
||||
return wda.getScreenSize();
|
||||
}
|
||||
|
||||
public async sendKeys(keys: string) {
|
||||
const wda = await this.wda();
|
||||
return wda.sendKeys(keys);
|
||||
}
|
||||
|
||||
public async swipe(direction: SwipeDirection): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
return wda.swipe(direction);
|
||||
}
|
||||
|
||||
public async swipeFromCoordinate(
|
||||
x: number,
|
||||
y: number,
|
||||
direction: SwipeDirection,
|
||||
distance?: number,
|
||||
): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
return wda.swipeFromCoordinate(x, y, direction, distance);
|
||||
}
|
||||
|
||||
public async tap(x: number, y: number) {
|
||||
const wda = await this.wda();
|
||||
return wda.tap(x, y);
|
||||
}
|
||||
|
||||
public async doubleTap(x: number, y: number): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
await wda.doubleTap(x, y);
|
||||
}
|
||||
|
||||
public async longPress(x: number, y: number, duration: number) {
|
||||
const wda = await this.wda();
|
||||
return wda.longPress(x, y, duration);
|
||||
}
|
||||
|
||||
public async pressButton(button: Button) {
|
||||
const wda = await this.wda();
|
||||
return wda.pressButton(button);
|
||||
}
|
||||
|
||||
public async getElementsOnScreen(): Promise<ScreenElement[]> {
|
||||
const wda = await this.wda();
|
||||
return wda.getElementsOnScreen();
|
||||
}
|
||||
|
||||
public async setOrientation(orientation: Orientation): Promise<void> {
|
||||
const wda = await this.wda();
|
||||
return wda.setOrientation(orientation);
|
||||
}
|
||||
|
||||
public async getOrientation(): Promise<Orientation> {
|
||||
const wda = await this.wda();
|
||||
return wda.getOrientation();
|
||||
}
|
||||
}
|
||||
21
packages/mobile-mcp/src/logger.ts
Normal file
21
packages/mobile-mcp/src/logger.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { appendFileSync } from "node:fs";
|
||||
|
||||
const writeLog = (message: string) => {
|
||||
if (process.env.LOG_FILE) {
|
||||
const logfile = process.env.LOG_FILE;
|
||||
const timestamp = new Date().toISOString();
|
||||
const levelStr = "INFO";
|
||||
const logMessage = `[${timestamp}] ${levelStr} ${message}`;
|
||||
appendFileSync(logfile, logMessage + "\n");
|
||||
}
|
||||
|
||||
console.error(message);
|
||||
};
|
||||
|
||||
export const trace = (message: string) => {
|
||||
writeLog(message);
|
||||
};
|
||||
|
||||
export const error = (message: string) => {
|
||||
writeLog(message);
|
||||
};
|
||||
263
packages/mobile-mcp/src/mobile-device.ts
Normal file
263
packages/mobile-mcp/src/mobile-device.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { Mobilecli } from './mobilecli';
|
||||
import {
|
||||
Button,
|
||||
InstalledApp,
|
||||
InstallOptions,
|
||||
Orientation,
|
||||
Robot,
|
||||
ScreenElement,
|
||||
ScreenSize,
|
||||
SwipeDirection,
|
||||
} from './robot';
|
||||
|
||||
interface InstalledAppsResponse {
|
||||
status: 'ok';
|
||||
data: Array<{
|
||||
packageName: string;
|
||||
appName?: string; // ios
|
||||
version?: string; // ios
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DeviceInfoResponse {
|
||||
status: 'ok';
|
||||
data: {
|
||||
device: {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
type: string;
|
||||
version: string;
|
||||
state: string;
|
||||
screenSize?: {
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface UIElementResponse {
|
||||
type: string;
|
||||
label?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
identifier?: string;
|
||||
rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
interface DumpUIResponse {
|
||||
status: 'ok';
|
||||
data: {
|
||||
elements: UIElementResponse[];
|
||||
};
|
||||
}
|
||||
|
||||
interface OrientationResponse {
|
||||
status: 'ok';
|
||||
data: {
|
||||
orientation: Orientation;
|
||||
};
|
||||
}
|
||||
|
||||
export class MobileDevice implements Robot {
|
||||
private mobilecli: Mobilecli;
|
||||
|
||||
public constructor(private deviceId: string) {
|
||||
this.mobilecli = new Mobilecli();
|
||||
}
|
||||
|
||||
private runCommand(args: string[]): string {
|
||||
const fullArgs = [...args, '--device', this.deviceId];
|
||||
return this.mobilecli.executeCommand(fullArgs);
|
||||
}
|
||||
|
||||
public async getScreenSize(): Promise<ScreenSize> {
|
||||
const response = JSON.parse(
|
||||
this.runCommand(['device', 'info']),
|
||||
) as DeviceInfoResponse;
|
||||
if (response.data.device.screenSize) {
|
||||
return response.data.device.screenSize;
|
||||
}
|
||||
return { width: 0, height: 0, scale: 1.0 };
|
||||
}
|
||||
|
||||
public async swipe(direction: SwipeDirection): Promise<void> {
|
||||
const screenSize = await this.getScreenSize();
|
||||
const centerX = Math.floor(screenSize.width / 2);
|
||||
const centerY = Math.floor(screenSize.height / 2);
|
||||
const distance = 400; // Default distance in pixels
|
||||
|
||||
let startX = centerX;
|
||||
let startY = centerY;
|
||||
let endX = centerX;
|
||||
let endY = centerY;
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
startY = centerY + distance / 2;
|
||||
endY = centerY - distance / 2;
|
||||
break;
|
||||
case 'down':
|
||||
startY = centerY - distance / 2;
|
||||
endY = centerY + distance / 2;
|
||||
break;
|
||||
case 'left':
|
||||
startX = centerX + distance / 2;
|
||||
endX = centerX - distance / 2;
|
||||
break;
|
||||
case 'right':
|
||||
startX = centerX - distance / 2;
|
||||
endX = centerX + distance / 2;
|
||||
break;
|
||||
}
|
||||
|
||||
this.runCommand(['io', 'swipe', `${startX},${startY},${endX},${endY}`]);
|
||||
}
|
||||
|
||||
public async swipeFromCoordinate(
|
||||
x: number,
|
||||
y: number,
|
||||
direction: SwipeDirection,
|
||||
distance?: number,
|
||||
): Promise<void> {
|
||||
const swipeDistance = distance || 400;
|
||||
let endX = x;
|
||||
let endY = y;
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
endY = y - swipeDistance;
|
||||
break;
|
||||
case 'down':
|
||||
endY = y + swipeDistance;
|
||||
break;
|
||||
case 'left':
|
||||
endX = x - swipeDistance;
|
||||
break;
|
||||
case 'right':
|
||||
endX = x + swipeDistance;
|
||||
break;
|
||||
}
|
||||
|
||||
this.runCommand(['io', 'swipe', `${x},${y},${endX},${endY}`]);
|
||||
}
|
||||
|
||||
public async getScreenshot(): Promise<Buffer> {
|
||||
const fullArgs = [
|
||||
'screenshot',
|
||||
'--device',
|
||||
this.deviceId,
|
||||
'--format',
|
||||
'png',
|
||||
'--output',
|
||||
'-',
|
||||
];
|
||||
return this.mobilecli.executeCommandBuffer(fullArgs);
|
||||
}
|
||||
|
||||
public async listApps(): Promise<InstalledApp[]> {
|
||||
const response = JSON.parse(
|
||||
this.runCommand(['apps', 'list']),
|
||||
) as InstalledAppsResponse;
|
||||
return response.data.map((app) => ({
|
||||
appName: app.appName || app.packageName,
|
||||
packageName: app.packageName,
|
||||
})) as InstalledApp[];
|
||||
}
|
||||
|
||||
public async launchApp(packageName: string, locale?: string): Promise<void> {
|
||||
const args = ['apps', 'launch', packageName];
|
||||
if (locale) {
|
||||
args.push('--locale', locale);
|
||||
}
|
||||
|
||||
this.runCommand(args);
|
||||
}
|
||||
|
||||
public async terminateApp(packageName: string): Promise<void> {
|
||||
this.runCommand(['apps', 'terminate', packageName]);
|
||||
}
|
||||
|
||||
public async installApp(
|
||||
path: string,
|
||||
_options?: InstallOptions,
|
||||
): Promise<void> {
|
||||
this.runCommand(['apps', 'install', path]);
|
||||
}
|
||||
|
||||
public async uninstallApp(bundleId: string): Promise<void> {
|
||||
this.runCommand(['apps', 'uninstall', bundleId]);
|
||||
}
|
||||
|
||||
public async openUrl(url: string): Promise<void> {
|
||||
this.runCommand(['url', url]);
|
||||
}
|
||||
|
||||
public async sendKeys(text: string): Promise<void> {
|
||||
this.runCommand(['io', 'text', text]);
|
||||
}
|
||||
|
||||
public async pressButton(button: Button): Promise<void> {
|
||||
this.runCommand(['io', 'button', button]);
|
||||
}
|
||||
|
||||
public async tap(x: number, y: number): Promise<void> {
|
||||
this.runCommand(['io', 'tap', `${x},${y}`]);
|
||||
}
|
||||
|
||||
public async doubleTap(x: number, y: number): Promise<void> {
|
||||
// TODO: should move into mobilecli itself as "io doubletap"
|
||||
await this.tap(x, y);
|
||||
await this.tap(x, y);
|
||||
}
|
||||
|
||||
public async longPress(
|
||||
x: number,
|
||||
y: number,
|
||||
duration: number,
|
||||
): Promise<void> {
|
||||
this.runCommand([
|
||||
'io',
|
||||
'longpress',
|
||||
`${x},${y}`,
|
||||
'--duration',
|
||||
`${duration}`,
|
||||
]);
|
||||
}
|
||||
|
||||
public async getElementsOnScreen(): Promise<ScreenElement[]> {
|
||||
const response = JSON.parse(
|
||||
this.runCommand(['dump', 'ui']),
|
||||
) as DumpUIResponse;
|
||||
return response.data.elements.map((element) => ({
|
||||
type: element.type,
|
||||
label: element.label,
|
||||
text: element.text,
|
||||
name: element.name,
|
||||
value: element.value,
|
||||
identifier: element.identifier,
|
||||
rect: element.rect,
|
||||
focused: element.focused,
|
||||
}));
|
||||
}
|
||||
|
||||
public async setOrientation(orientation: Orientation): Promise<void> {
|
||||
this.runCommand(['device', 'orientation', 'set', orientation]);
|
||||
}
|
||||
|
||||
public async getOrientation(): Promise<Orientation> {
|
||||
const response = JSON.parse(
|
||||
this.runCommand(['device', 'orientation', 'get']),
|
||||
) as OrientationResponse;
|
||||
return response.data.orientation;
|
||||
}
|
||||
}
|
||||
207
packages/mobile-mcp/src/mobilecli.ts
Normal file
207
packages/mobile-mcp/src/mobilecli.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, sep } from "node:path";
|
||||
import { execFileSync, spawn, ChildProcess } from "node:child_process";
|
||||
|
||||
export interface MobilecliCrashEntry {
|
||||
processName: string;
|
||||
timestamp: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface MobilecliCrashesListResponse {
|
||||
status: "ok";
|
||||
data: MobilecliCrashEntry[];
|
||||
}
|
||||
|
||||
export interface MobilecliCrashGetResponse {
|
||||
status: "ok";
|
||||
data: {
|
||||
content: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MobilecliAgentStatusResponse {
|
||||
status: "ok" | "fail";
|
||||
data: {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MobilecliDevicesOptions {
|
||||
includeOffline?: boolean;
|
||||
platform?: "ios" | "android";
|
||||
type?: "real" | "emulator" | "simulator";
|
||||
}
|
||||
|
||||
export interface MobilecliDeviceProvider {
|
||||
type: string; // e.g. "mobilefleet" for remote devices
|
||||
allocationId?: string;
|
||||
}
|
||||
|
||||
export interface MobilecliDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: "android" | "ios";
|
||||
type: "real" | "emulator" | "simulator";
|
||||
version: string;
|
||||
provider?: MobilecliDeviceProvider;
|
||||
}
|
||||
|
||||
export interface MobilecliDevicesResponse {
|
||||
status: "ok";
|
||||
data: {
|
||||
devices: MobilecliDevice[];
|
||||
};
|
||||
}
|
||||
|
||||
const TIMEOUT = 30000;
|
||||
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;
|
||||
|
||||
export class Mobilecli {
|
||||
private path: string | null = null;
|
||||
|
||||
constructor() { }
|
||||
|
||||
private getPath(): string {
|
||||
if (!this.path) {
|
||||
this.path = Mobilecli.getMobilecliPath();
|
||||
}
|
||||
return this.path;
|
||||
}
|
||||
|
||||
public executeCommand(args: string[]): string {
|
||||
const path = this.getPath();
|
||||
return execFileSync(path, args, { encoding: "utf8" }).toString().trim();
|
||||
}
|
||||
|
||||
public spawnCommand(args: string[]): ChildProcess {
|
||||
const binaryPath = this.getPath();
|
||||
return spawn(binaryPath, args, {
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
}
|
||||
|
||||
public executeCommandBuffer(args: string[]): Buffer {
|
||||
const path = this.getPath();
|
||||
return execFileSync(path, args, {
|
||||
encoding: "buffer",
|
||||
maxBuffer: MAX_BUFFER_SIZE,
|
||||
timeout: TIMEOUT,
|
||||
}) as Buffer;
|
||||
}
|
||||
|
||||
private static getMobilecliPath(): string {
|
||||
if (process.env.MOBILECLI_PATH) {
|
||||
return process.env.MOBILECLI_PATH;
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
const normalizedPlatform = platform === "win32" ? "windows" : platform;
|
||||
const normalizedArch = arch === "arm64" ? "arm64" : "amd64";
|
||||
const ext = platform === "win32" ? ".exe" : "";
|
||||
const binaryName = `mobilecli-${normalizedPlatform}-${normalizedArch}${ext}`;
|
||||
|
||||
// Check if mobile-mcp is installed as a package
|
||||
const currentPath = __filename;
|
||||
const pathParts = currentPath.split(sep);
|
||||
const lastNodeModulesIndex = pathParts.lastIndexOf("node_modules");
|
||||
|
||||
if (lastNodeModulesIndex !== -1) {
|
||||
// We're inside node_modules, go to the last node_modules in the path
|
||||
const nodeModulesParts = pathParts.slice(0, lastNodeModulesIndex + 1);
|
||||
const lastNodeModulesPath = nodeModulesParts.join(sep);
|
||||
const mobilecliPath = join(lastNodeModulesPath, "mobilecli", "bin", binaryName);
|
||||
|
||||
if (existsSync(mobilecliPath)) {
|
||||
return mobilecliPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Not in node_modules, look one directory up from current script
|
||||
const scriptDir = dirname(__filename);
|
||||
const parentDir = dirname(scriptDir);
|
||||
const mobilecliPath = join(parentDir, "node_modules", "mobilecli", "bin", binaryName);
|
||||
|
||||
if (existsSync(mobilecliPath)) {
|
||||
return mobilecliPath;
|
||||
}
|
||||
|
||||
throw new Error(`Could not find mobilecli binary for platform: ${platform}`);
|
||||
}
|
||||
|
||||
getVersion(): string {
|
||||
try {
|
||||
const output = this.executeCommand(["--version"]);
|
||||
if (output.startsWith("mobilecli version ")) {
|
||||
return output.substring("mobilecli version ".length);
|
||||
}
|
||||
|
||||
return "failed";
|
||||
} catch (error: any) {
|
||||
return "failed " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
remoteListDevices(): string {
|
||||
return this.executeCommand(["remote", "list-devices"]);
|
||||
}
|
||||
|
||||
remoteAllocate(platform: "ios" | "android"): string {
|
||||
return this.executeCommand(["remote", "allocate", "--platform", platform]);
|
||||
}
|
||||
|
||||
remoteRelease(deviceId: string): string {
|
||||
return this.executeCommand(["remote", "release", "--device", deviceId]);
|
||||
}
|
||||
|
||||
crashesList(deviceId: string): MobilecliCrashesListResponse {
|
||||
const output = this.executeCommand(["device", "crashes", "list", "--device", deviceId]);
|
||||
return JSON.parse(output) as MobilecliCrashesListResponse;
|
||||
}
|
||||
|
||||
crashesGet(deviceId: string, id: string): MobilecliCrashGetResponse {
|
||||
const output = this.executeCommandBuffer(["device", "crashes", "get", id, "--device", deviceId]);
|
||||
return JSON.parse(output.toString().trim()) as MobilecliCrashGetResponse;
|
||||
}
|
||||
|
||||
agentStatus(deviceId: string): MobilecliAgentStatusResponse {
|
||||
const output = this.executeCommand(["agent", "status", "--device", deviceId]);
|
||||
return JSON.parse(output) as MobilecliAgentStatusResponse;
|
||||
}
|
||||
|
||||
agentInstall(deviceId: string): void {
|
||||
this.executeCommand(["agent", "install", "--device", deviceId]);
|
||||
}
|
||||
|
||||
getDevices(options?: MobilecliDevicesOptions): MobilecliDevicesResponse {
|
||||
const args = ["devices"];
|
||||
|
||||
if (options) {
|
||||
if (options.includeOffline) {
|
||||
args.push("--include-offline");
|
||||
}
|
||||
|
||||
if (options.platform) {
|
||||
if (options.platform !== "ios" && options.platform !== "android") {
|
||||
throw new Error(`Invalid platform: ${options.platform}. Must be "ios" or "android"`);
|
||||
}
|
||||
|
||||
args.push("--platform", options.platform);
|
||||
}
|
||||
|
||||
if (options.type) {
|
||||
if (options.type !== "real" && options.type !== "emulator" && options.type !== "simulator") {
|
||||
throw new Error(`Invalid type: ${options.type}. Must be "real", "emulator", or "simulator"`);
|
||||
}
|
||||
|
||||
args.push("--type", options.type);
|
||||
}
|
||||
}
|
||||
|
||||
const mobilecliOutput = this.executeCommand(args);
|
||||
return JSON.parse(mobilecliOutput) as MobilecliDevicesResponse;
|
||||
}
|
||||
}
|
||||
20
packages/mobile-mcp/src/png.ts
Normal file
20
packages/mobile-mcp/src/png.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export interface PngDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class PNG {
|
||||
public constructor(private readonly buffer: Buffer) {
|
||||
}
|
||||
|
||||
public getDimensions(): PngDimensions {
|
||||
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
if (!this.buffer.subarray(0, 8).equals(pngSignature)) {
|
||||
throw new Error("Not a valid PNG file");
|
||||
}
|
||||
|
||||
const width = this.buffer.readUInt32BE(16);
|
||||
const height = this.buffer.readUInt32BE(20);
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
172
packages/mobile-mcp/src/robot.ts
Normal file
172
packages/mobile-mcp/src/robot.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
export interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ScreenSize extends Dimensions {
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface InstalledApp {
|
||||
packageName: string;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
export type SwipeDirection = 'up' | 'down' | 'left' | 'right';
|
||||
|
||||
export type Button =
|
||||
| 'HOME'
|
||||
| 'BACK'
|
||||
| 'VOLUME_UP'
|
||||
| 'VOLUME_DOWN'
|
||||
| 'ENTER'
|
||||
| 'DPAD_CENTER'
|
||||
| 'DPAD_UP'
|
||||
| 'DPAD_DOWN'
|
||||
| 'DPAD_LEFT'
|
||||
| 'DPAD_RIGHT';
|
||||
|
||||
export interface ScreenElementRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ScreenElement {
|
||||
type: string;
|
||||
label?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
identifier?: string;
|
||||
rect: ScreenElementRect;
|
||||
|
||||
// currently only on android tv
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
export class ActionableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstallOptions {
|
||||
/** -r: replace existing application (default true for backward compatibility) */
|
||||
replace?: boolean;
|
||||
/** -g: grant all runtime permissions on install */
|
||||
grantPermissions?: boolean;
|
||||
/** -d: allow version code downgrade */
|
||||
allowDowngrade?: boolean;
|
||||
/** -t: allow test APKs */
|
||||
allowTest?: boolean;
|
||||
}
|
||||
|
||||
export type Orientation = 'portrait' | 'landscape';
|
||||
|
||||
export interface Robot {
|
||||
/**
|
||||
* Get the screen size of the device in pixels.
|
||||
*/
|
||||
getScreenSize(): Promise<ScreenSize>;
|
||||
|
||||
/**
|
||||
* Swipe in a direction.
|
||||
*/
|
||||
swipe(direction: SwipeDirection): Promise<void>;
|
||||
|
||||
/**
|
||||
* Swipe from a specific coordinate in a direction.
|
||||
*/
|
||||
swipeFromCoordinate(
|
||||
x: number,
|
||||
y: number,
|
||||
direction: SwipeDirection,
|
||||
distance?: number,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a screenshot of the screen. Returns a Buffer that contains
|
||||
* a PNG image of the screen. Will be same dimensions as getScreenSize().
|
||||
*/
|
||||
getScreenshot(): Promise<Buffer>;
|
||||
|
||||
/**
|
||||
* List all installed apps on the device. Returns an array of package names (or
|
||||
* bundle identifiers in iOS) for all installed apps.
|
||||
*/
|
||||
listApps(): Promise<InstalledApp[]>;
|
||||
|
||||
/**
|
||||
* Launch an app.
|
||||
*/
|
||||
launchApp(packageName: string, locale?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Terminate an app. If app was already terminated (or non existent) then this
|
||||
* is a no-op.
|
||||
*/
|
||||
terminateApp(packageName: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Install an app on the device from a file path.
|
||||
*/
|
||||
installApp(path: string, options?: InstallOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Uninstall an app from the device.
|
||||
*/
|
||||
uninstallApp(bundleId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Open a URL in the device's web browser. Can be an https:// url, or a
|
||||
* custom scheme (e.g. "myapp://").
|
||||
*/
|
||||
openUrl(url: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send keys to the device, simulating keyboard input.
|
||||
*/
|
||||
sendKeys(text: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Press a button on the device, simulating a physical button press.
|
||||
*/
|
||||
pressButton(button: Button): Promise<void>;
|
||||
|
||||
/**
|
||||
* Tap on a specific coordinate on the screen.
|
||||
*/
|
||||
tap(x: number, y: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Tap on a specific coordinate on the screen.
|
||||
*/
|
||||
doubleTap(x: number, y: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Long press on a specific coordinate on the screen.
|
||||
* @param x - The x coordinate to long press
|
||||
* @param y - The y coordinate to long press
|
||||
* @param duration - Duration of the long press in milliseconds
|
||||
*/
|
||||
longPress(x: number, y: number, duration: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get all elements on the screen. Works only on native apps (not webviews). Will
|
||||
* return a filtered list of elements that make sense to interact with.
|
||||
*/
|
||||
getElementsOnScreen(): Promise<ScreenElement[]>;
|
||||
|
||||
/**
|
||||
* Change the screen orientation of the device.
|
||||
* @param orientation The desired orientation ("portrait" or "landscape")
|
||||
*/
|
||||
setOrientation(orientation: Orientation): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the current screen orientation.
|
||||
*/
|
||||
getOrientation(): Promise<Orientation>;
|
||||
}
|
||||
1329
packages/mobile-mcp/src/server.ts
Normal file
1329
packages/mobile-mcp/src/server.ts
Normal file
File diff suppressed because it is too large
Load diff
88
packages/mobile-mcp/src/utils.ts
Normal file
88
packages/mobile-mcp/src/utils.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import { ActionableError } from "./robot";
|
||||
|
||||
export function validatePackageName(packageName: string): void {
|
||||
if (!/^[a-zA-Z0-9._]+$/.test(packageName)) {
|
||||
throw new ActionableError(`Invalid package name: "${packageName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateLocale(locale: string): void {
|
||||
if (!/^[a-zA-Z0-9,\- ]+$/.test(locale)) {
|
||||
throw new ActionableError(`Invalid locale: "${locale}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function getAllowedRoots(): string[] {
|
||||
const roots = [
|
||||
os.tmpdir(),
|
||||
process.cwd(),
|
||||
];
|
||||
|
||||
// macOS /tmp is a symlink to /private/tmp, add both to be safe
|
||||
if (process.platform === "darwin") {
|
||||
roots.push("/tmp");
|
||||
roots.push("/private/tmp");
|
||||
}
|
||||
|
||||
return roots.map(r => path.resolve(r));
|
||||
}
|
||||
|
||||
function isPathUnderRoot(filePath: string, root: string): boolean {
|
||||
const relative = path.relative(root, filePath);
|
||||
if (relative === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (relative.startsWith("..")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateFileExtension(filePath: string, allowedExtensions: string[], toolName: string): void {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
throw new ActionableError(`${toolName} requires a ${allowedExtensions.join(", ")} file extension, got: "${ext || "(none)"}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWithSymlinks(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
const dir = path.dirname(resolved);
|
||||
const filename = path.basename(resolved);
|
||||
|
||||
try {
|
||||
return path.join(fs.realpathSync(dir), filename);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateOutputPath(filePath: string): void {
|
||||
const resolved = resolveWithSymlinks(filePath);
|
||||
const allowedRoots = getAllowedRoots();
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
const isAllowed = allowedRoots.some(root => {
|
||||
if (isWindows) {
|
||||
return isPathUnderRoot(resolved.toLowerCase(), root.toLowerCase());
|
||||
}
|
||||
|
||||
return isPathUnderRoot(resolved, root);
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
const dir = path.dirname(resolved);
|
||||
throw new ActionableError(
|
||||
`"${dir}" is not in the list of allowed directories. Allowed directories include the current directory and the temp directory on this host.`
|
||||
);
|
||||
}
|
||||
}
|
||||
454
packages/mobile-mcp/src/webdriver-agent.ts
Normal file
454
packages/mobile-mcp/src/webdriver-agent.ts
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
import { ActionableError, SwipeDirection, ScreenSize, ScreenElement, Orientation } from "./robot";
|
||||
|
||||
export interface SourceTreeElementRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface SourceTreeElement {
|
||||
type: string;
|
||||
label?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
rawIdentifier?: string;
|
||||
rect: SourceTreeElementRect;
|
||||
isVisible?: string; // "0" or "1"
|
||||
children?: Array<SourceTreeElement>;
|
||||
}
|
||||
|
||||
export interface SourceTree {
|
||||
value: SourceTreeElement;
|
||||
}
|
||||
|
||||
export class WebDriverAgent {
|
||||
|
||||
constructor(private readonly host: string, private readonly port: number) {
|
||||
}
|
||||
|
||||
public async isRunning(): Promise<boolean> {
|
||||
const url = `http://${this.host}:${this.port}/status`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const json = await response.json();
|
||||
return response.status === 200 && json.value?.ready === true;
|
||||
} catch (error) {
|
||||
// console.error(`Failed to connect to WebDriverAgent: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async createSession(): Promise<string> {
|
||||
const url = `http://${this.host}:${this.port}/session`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ capabilities: { alwaysMatch: { platformName: "iOS" } } }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new ActionableError(`Failed to create WebDriver session: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (!json.value || !json.value.sessionId) {
|
||||
throw new ActionableError(`Invalid session response: ${JSON.stringify(json)}`);
|
||||
}
|
||||
|
||||
return json.value.sessionId;
|
||||
}
|
||||
|
||||
public async deleteSession(sessionId: string) {
|
||||
const url = `http://${this.host}:${this.port}/session/${sessionId}`;
|
||||
const response = await fetch(url, { method: "DELETE" });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
public async withinSession(fn: (url: string) => Promise<any>) {
|
||||
const sessionId = await this.createSession();
|
||||
const url = `http://${this.host}:${this.port}/session/${sessionId}`;
|
||||
const result = await fn(url);
|
||||
await this.deleteSession(sessionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async getScreenSize(sessionUrl?: string): Promise<ScreenSize> {
|
||||
if (sessionUrl) {
|
||||
const url = `${sessionUrl}/wda/screen`;
|
||||
const response = await fetch(url);
|
||||
const json = await response.json();
|
||||
return {
|
||||
width: json.value.screenSize.width,
|
||||
height: json.value.screenSize.height,
|
||||
scale: json.value.scale || 1,
|
||||
};
|
||||
} else {
|
||||
return this.withinSession(async sessionUrlInner => {
|
||||
const url = `${sessionUrlInner}/wda/screen`;
|
||||
const response = await fetch(url);
|
||||
const json = await response.json();
|
||||
return {
|
||||
width: json.value.screenSize.width,
|
||||
height: json.value.screenSize.height,
|
||||
scale: json.value.scale || 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async sendKeys(keys: string) {
|
||||
await this.withinSession(async sessionUrl => {
|
||||
const url = `${sessionUrl}/wda/keys`;
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ value: [keys] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async pressButton(button: string) {
|
||||
const _map = {
|
||||
"HOME": "home",
|
||||
"VOLUME_UP": "volumeup",
|
||||
"VOLUME_DOWN": "volumedown",
|
||||
};
|
||||
|
||||
if (button === "ENTER") {
|
||||
await this.sendKeys("\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Type assertion to check if button is a key of _map
|
||||
if (!(button in _map)) {
|
||||
throw new ActionableError(`Button "${button}" is not supported`);
|
||||
}
|
||||
|
||||
await this.withinSession(async sessionUrl => {
|
||||
const url = `${sessionUrl}/wda/pressButton`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: button,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
public async tap(x: number, y: number) {
|
||||
await this.withinSession(async sessionUrl => {
|
||||
const url = `${sessionUrl}/actions`;
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
actions: [
|
||||
{
|
||||
type: "pointer",
|
||||
id: "finger1",
|
||||
parameters: { pointerType: "touch" },
|
||||
actions: [
|
||||
{ type: "pointerMove", duration: 0, x, y },
|
||||
{ type: "pointerDown", button: 0 },
|
||||
{ type: "pause", duration: 100 },
|
||||
{ type: "pointerUp", button: 0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async doubleTap(x: number, y: number) {
|
||||
await this.withinSession(async sessionUrl => {
|
||||
const url = `${sessionUrl}/actions`;
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
actions: [
|
||||
{
|
||||
type: "pointer",
|
||||
id: "finger1",
|
||||
parameters: { pointerType: "touch" },
|
||||
actions: [
|
||||
{ type: "pointerMove", duration: 0, x, y },
|
||||
{ type: "pointerDown", button: 0 },
|
||||
{ type: "pause", duration: 50 },
|
||||
{ type: "pointerUp", button: 0 },
|
||||
|
||||
{ type: "pause", duration: 100 },
|
||||
|
||||
{ type: "pointerDown", button: 0 },
|
||||
{ type: "pause", duration: 50 },
|
||||
{ type: "pointerUp", button: 0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async longPress(x: number, y: number, duration: number) {
|
||||
await this.withinSession(async sessionUrl => {
|
||||
const url = `${sessionUrl}/actions`;
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
actions: [
|
||||
{
|
||||
type: "pointer",
|
||||
id: "finger1",
|
||||
parameters: { pointerType: "touch" },
|
||||
actions: [
|
||||
{ type: "pointerMove", duration: 0, x, y },
|
||||
{ type: "pointerDown", button: 0 },
|
||||
{ type: "pause", duration },
|
||||
{ type: "pointerUp", button: 0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private isVisible(rect: SourceTreeElementRect): boolean {
|
||||
return rect.x >= 0 && rect.y >= 0;
|
||||
}
|
||||
|
||||
private filterSourceElements(source: SourceTreeElement): Array<ScreenElement> {
|
||||
const output: ScreenElement[] = [];
|
||||
|
||||
const acceptedTypes = ["TextField", "Button", "Switch", "Icon", "SearchField", "StaticText", "Image"];
|
||||
|
||||
if (acceptedTypes.includes(source.type)) {
|
||||
if (source.isVisible === "1" && this.isVisible(source.rect)) {
|
||||
if (source.label !== null || source.name !== null || source.rawIdentifier !== null) {
|
||||
output.push({
|
||||
type: source.type,
|
||||
label: source.label,
|
||||
name: source.name,
|
||||
value: source.value,
|
||||
identifier: source.rawIdentifier,
|
||||
rect: {
|
||||
x: source.rect.x,
|
||||
y: source.rect.y,
|
||||
width: source.rect.width,
|
||||
height: source.rect.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (source.children) {
|
||||
for (const child of source.children) {
|
||||
output.push(...this.filterSourceElements(child));
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async getPageSource(): Promise<SourceTree> {
|
||||
const url = `http://${this.host}:${this.port}/source/?format=json`;
|
||||
const response = await fetch(url);
|
||||
const json = await response.json();
|
||||
return json as SourceTree;
|
||||
}
|
||||
|
||||
public async getElementsOnScreen(): Promise<ScreenElement[]> {
|
||||
const source = await this.getPageSource();
|
||||
return this.filterSourceElements(source.value);
|
||||
}
|
||||
|
||||
public async openUrl(url: string): Promise<void> {
|
||||
await this.withinSession(async sessionUrl => {
|
||||
await fetch(`${sessionUrl}/url`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async getScreenshot(): Promise<Buffer> {
|
||||
const url = `http://${this.host}:${this.port}/screenshot`;
|
||||
const response = await fetch(url);
|
||||
const json = await response.json();
|
||||
return Buffer.from(json.value, "base64");
|
||||
}
|
||||
|
||||
public async swipe(direction: SwipeDirection): Promise<void> {
|
||||
await this.withinSession(async sessionUrl => {
|
||||
const screenSize = await this.getScreenSize(sessionUrl);
|
||||
let x0: number, y0: number, x1: number, y1: number;
|
||||
// Use 60% of the width/height for swipe distance
|
||||
const verticalDistance = Math.floor(screenSize.height * 0.6);
|
||||
const horizontalDistance = Math.floor(screenSize.width * 0.6);
|
||||
const centerX = Math.floor(screenSize.width / 2);
|
||||
const centerY = Math.floor(screenSize.height / 2);
|
||||
|
||||
switch (direction) {
|
||||
case "up":
|
||||
x0 = x1 = centerX;
|
||||
y0 = centerY + Math.floor(verticalDistance / 2);
|
||||
y1 = centerY - Math.floor(verticalDistance / 2);
|
||||
break;
|
||||
case "down":
|
||||
x0 = x1 = centerX;
|
||||
y0 = centerY - Math.floor(verticalDistance / 2);
|
||||
y1 = centerY + Math.floor(verticalDistance / 2);
|
||||
break;
|
||||
case "left":
|
||||
y0 = y1 = centerY;
|
||||
x0 = centerX + Math.floor(horizontalDistance / 2);
|
||||
x1 = centerX - Math.floor(horizontalDistance / 2);
|
||||
break;
|
||||
case "right":
|
||||
y0 = y1 = centerY;
|
||||
x0 = centerX - Math.floor(horizontalDistance / 2);
|
||||
x1 = centerX + Math.floor(horizontalDistance / 2);
|
||||
break;
|
||||
default:
|
||||
throw new ActionableError(`Swipe direction "${direction}" is not supported`);
|
||||
}
|
||||
|
||||
const url = `${sessionUrl}/actions`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
actions: [
|
||||
{
|
||||
type: "pointer",
|
||||
id: "finger1",
|
||||
parameters: { pointerType: "touch" },
|
||||
actions: [
|
||||
{ type: "pointerMove", duration: 0, x: x0, y: y0 },
|
||||
{ type: "pointerDown", button: 0 },
|
||||
{ type: "pointerMove", duration: 1000, x: x1, y: y1 },
|
||||
{ type: "pointerUp", button: 0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new ActionableError(`WebDriver actions request failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
// Clear actions to ensure they complete
|
||||
await fetch(`${sessionUrl}/actions`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance: number = 400): Promise<void> {
|
||||
await this.withinSession(async sessionUrl => {
|
||||
// Use simple coordinates like the working swipe method
|
||||
const x0 = x;
|
||||
const y0 = y;
|
||||
let x1 = x;
|
||||
let y1 = y;
|
||||
|
||||
// Calculate target position based on direction and distance
|
||||
switch (direction) {
|
||||
case "up":
|
||||
y1 = y - distance; // Move up by specified distance
|
||||
break;
|
||||
case "down":
|
||||
y1 = y + distance; // Move down by specified distance
|
||||
break;
|
||||
case "left":
|
||||
x1 = x - distance; // Move left by specified distance
|
||||
break;
|
||||
case "right":
|
||||
x1 = x + distance; // Move right by specified distance
|
||||
break;
|
||||
default:
|
||||
throw new ActionableError(`Swipe direction "${direction}" is not supported`);
|
||||
}
|
||||
|
||||
const url = `${sessionUrl}/actions`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
actions: [
|
||||
{
|
||||
type: "pointer",
|
||||
id: "finger1",
|
||||
parameters: { pointerType: "touch" },
|
||||
actions: [
|
||||
{ type: "pointerMove", duration: 0, x: x0, y: y0 },
|
||||
{ type: "pointerDown", button: 0 },
|
||||
{ type: "pointerMove", duration: 1000, x: x1, y: y1 },
|
||||
{ type: "pointerUp", button: 0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new ActionableError(`WebDriver actions request failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
// Clear actions to ensure they complete
|
||||
await fetch(`${sessionUrl}/actions`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async setOrientation(orientation: Orientation): Promise<void> {
|
||||
await this.withinSession(async sessionUrl => {
|
||||
const url = `${sessionUrl}/orientation`;
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
orientation: orientation.toUpperCase()
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async getOrientation(): Promise<Orientation> {
|
||||
return this.withinSession(async sessionUrl => {
|
||||
const url = `${sessionUrl}/orientation`;
|
||||
const response = await fetch(url);
|
||||
const json = await response.json();
|
||||
return json.value.toLowerCase() as Orientation;
|
||||
});
|
||||
}
|
||||
}
|
||||
139
packages/mobile-mcp/test/android.ts
Normal file
139
packages/mobile-mcp/test/android.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
import { PNG } from "../src/png";
|
||||
import { AndroidRobot, AndroidDeviceManager } from "../src/android";
|
||||
|
||||
const manager = new AndroidDeviceManager();
|
||||
const devices = manager.getConnectedDevices();
|
||||
const hasOneAndroidDevice = devices.length === 1;
|
||||
|
||||
test.describe("android", () => {
|
||||
|
||||
const android = new AndroidRobot(devices?.[0]?.deviceId || "");
|
||||
|
||||
test("should be able to get the screen size", async () => {
|
||||
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
|
||||
const screenSize = await android.getScreenSize();
|
||||
expect(screenSize.width).toBeGreaterThan(1024);
|
||||
expect(screenSize.height).toBeGreaterThan(1024);
|
||||
expect(screenSize.scale).toBe(1);
|
||||
expect(Object.keys(screenSize).length, "screenSize should have exactly 3 properties").toBe(3);
|
||||
});
|
||||
|
||||
test("should be able to take screenshot", async () => {
|
||||
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
|
||||
|
||||
const screenSize = await android.getScreenSize();
|
||||
const screenshot = await android.getScreenshot();
|
||||
expect(screenshot.length).toBeGreaterThan(64 * 1024);
|
||||
|
||||
// must be a valid png image that matches the screen size
|
||||
const image = new PNG(screenshot);
|
||||
const pngSize = image.getDimensions();
|
||||
expect(pngSize.width).toBe(screenSize.width);
|
||||
expect(pngSize.height).toBe(screenSize.height);
|
||||
});
|
||||
|
||||
test("should be able to list apps", async () => {
|
||||
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
|
||||
const apps = await android.listApps();
|
||||
const packages = apps.map(app => app.packageName);
|
||||
expect(packages).toContain("com.android.settings");
|
||||
});
|
||||
|
||||
test("should be able to open a url", async () => {
|
||||
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
|
||||
await android.adb("shell", "input", "keyevent", "HOME");
|
||||
await android.openUrl("https://www.example.com");
|
||||
});
|
||||
|
||||
test("should be able to list elements on screen", async () => {
|
||||
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
|
||||
await android.terminateApp("com.android.chrome");
|
||||
await android.adb("shell", "input", "keyevent", "HOME");
|
||||
await android.openUrl("https://www.example.com");
|
||||
const elements = await android.getElementsOnScreen();
|
||||
|
||||
// make sure title (TextView) is present
|
||||
const foundTitle = elements.find(element => element.type === "android.widget.TextView" && element.text?.startsWith("This domain is for use in illustrative examples in documents"));
|
||||
expect(foundTitle, "Title element not found").toBeTruthy();
|
||||
|
||||
// make sure navbar (EditText) is present
|
||||
const foundNavbar = elements.find(element => element.type === "android.widget.EditText" && element.label === "Search or type URL" && element.text === "example.com");
|
||||
expect(foundNavbar, "Navbar element not found").toBeTruthy();
|
||||
|
||||
// this is an icon, but has accessibility label
|
||||
const foundSecureIcon = elements.find(element => element.type === "android.widget.ImageButton" && element.text === "" && element.label === "New tab");
|
||||
expect(foundSecureIcon, "New tab icon not found").toBeTruthy();
|
||||
});
|
||||
|
||||
test("should be able to send keys and tap", async () => {
|
||||
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
|
||||
await android.terminateApp("com.google.android.deskclock");
|
||||
await android.adb("shell", "pm", "clear", "com.google.android.deskclock");
|
||||
await android.launchApp("com.google.android.deskclock");
|
||||
|
||||
// We probably start at Clock tab
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
let elements = await android.getElementsOnScreen();
|
||||
const timerElement = elements.find(e => e.label === "Timer" && e.type === "android.widget.FrameLayout");
|
||||
expect(timerElement).toBeDefined();
|
||||
await android.tap(timerElement.rect.x, timerElement.rect.y);
|
||||
|
||||
// now we're in Timer tab
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
elements = await android.getElementsOnScreen();
|
||||
const currentTime = elements.find(e => e.text === "00h 00m 00s");
|
||||
expect(currentTime, "Expected time to be 00h 00m 00s").toBeDefined();
|
||||
await android.sendKeys("123456");
|
||||
|
||||
// now the title has changed with new timer
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
elements = await android.getElementsOnScreen();
|
||||
const newTime = elements.find(e => e.text === "12h 34m 56s");
|
||||
expect(newTime, "Expected time to be 12h 34m 56s").toBeDefined();
|
||||
|
||||
await android.terminateApp("com.google.android.deskclock");
|
||||
});
|
||||
|
||||
test("should be able to launch and terminate an app", async () => {
|
||||
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
|
||||
|
||||
// kill if running
|
||||
await android.terminateApp("com.android.chrome");
|
||||
|
||||
await android.launchApp("com.android.chrome");
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
const processes = await android.listRunningProcesses();
|
||||
expect(processes).toContain("com.android.chrome");
|
||||
|
||||
await android.terminateApp("com.android.chrome");
|
||||
const processes2 = await android.listRunningProcesses();
|
||||
expect(processes2).not.toContain("com.android.chrome");
|
||||
});
|
||||
|
||||
test("should handle orientation changes", async () => {
|
||||
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
|
||||
|
||||
// assume we start in portrait
|
||||
const originalOrientation = await android.getOrientation();
|
||||
expect(originalOrientation).toBe("portrait");
|
||||
const screenSize1 = await android.getScreenSize();
|
||||
|
||||
// set to landscape
|
||||
await android.setOrientation("landscape");
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
const orientation = await android.getOrientation();
|
||||
expect(orientation).toBe("landscape");
|
||||
const screenSize2 = await android.getScreenSize();
|
||||
|
||||
// set to portrait
|
||||
await android.setOrientation("portrait");
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
const orientation2 = await android.getOrientation();
|
||||
expect(orientation2).toBe("portrait");
|
||||
|
||||
// screen size should not have changed
|
||||
expect(screenSize1).toEqual(screenSize2);
|
||||
});
|
||||
});
|
||||
347
packages/mobile-mcp/test/coord-norm.test.ts
Normal file
347
packages/mobile-mcp/test/coord-norm.test.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// We need to test coord-norm functions. Since the module uses process.env,
|
||||
// we manipulate env vars in each test.
|
||||
|
||||
// Dynamic import to allow env var manipulation before module evaluation.
|
||||
async function loadCoordNorm(env: Record<string, string | undefined> = {}) {
|
||||
const origEnv: Record<string, string | undefined> = {};
|
||||
for (const [k, v] of Object.entries(env)) {
|
||||
origEnv[k] = process.env[k];
|
||||
if (v === undefined) {
|
||||
delete process.env[k];
|
||||
} else {
|
||||
process.env[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Force re-import by clearing require cache
|
||||
const modulePath = require.resolve('../src/coord-norm');
|
||||
delete require.cache[modulePath];
|
||||
const mod = require('../src/coord-norm');
|
||||
|
||||
return {
|
||||
mod,
|
||||
restore: () => {
|
||||
for (const [k, v] of Object.entries(origEnv)) {
|
||||
if (v === undefined) {
|
||||
delete process.env[k];
|
||||
} else {
|
||||
process.env[k] = v;
|
||||
}
|
||||
}
|
||||
delete require.cache[modulePath];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Scalar conversion tests ──
|
||||
|
||||
test('normToPx maps midpoint', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
expect(mod.normToPx(500, 800, 1000)).toBe(400);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('normToPx maps edges', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
expect(mod.normToPx(0, 800, 1000)).toBe(0);
|
||||
expect(mod.normToPx(1000, 800, 1000)).toBe(800);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('normToPx rounds to nearest', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
// 333/1000 * 800 = 266.4 → 266
|
||||
expect(mod.normToPx(333, 800, 1000)).toBe(266);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('pxToNorm is inverse at midpoint', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
expect(mod.pxToNorm(400, 800, 1000)).toBe(500);
|
||||
expect(mod.pxToNorm(800, 800, 1000)).toBe(1000);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('normToPx respects custom scale (999)', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
expect(mod.normToPx(999, 800, 999)).toBe(800);
|
||||
// Same input under scale 1000: 999/1000*800 = 799.2 → 799
|
||||
expect(mod.normToPx(999, 800, 1000)).toBe(799);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('pxToNorm handles zero dim', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
expect(mod.pxToNorm(400, 0, 1000)).toBe(0);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── denormalizeArgs tests ──
|
||||
|
||||
test('denormalizeArgs click uses width for x, height for y', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
const args: any = { device: 'd1', x: 500, y: 500 };
|
||||
mod.denormalizeArgs(
|
||||
'mobile_click_on_screen_at_coordinates',
|
||||
args,
|
||||
800,
|
||||
600,
|
||||
);
|
||||
expect(args.x).toBe(400); // 500/1000 * 800
|
||||
expect(args.y).toBe(300); // 500/1000 * 600
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('denormalizeArgs swipe converts x, y, and distance by direction', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
const argsDown: any = {
|
||||
device: 'd1',
|
||||
x: 500,
|
||||
y: 500,
|
||||
distance: 200,
|
||||
direction: 'down',
|
||||
};
|
||||
mod.denormalizeArgs('mobile_swipe_on_screen', argsDown, 800, 600);
|
||||
expect(argsDown.x).toBe(400);
|
||||
expect(argsDown.y).toBe(300);
|
||||
expect(argsDown.distance).toBe(120); // 200/1000 * 600 (height for down)
|
||||
|
||||
const argsRight: any = {
|
||||
device: 'd1',
|
||||
x: 500,
|
||||
y: 500,
|
||||
distance: 200,
|
||||
direction: 'right',
|
||||
};
|
||||
mod.denormalizeArgs('mobile_swipe_on_screen', argsRight, 800, 600);
|
||||
expect(argsRight.distance).toBe(160); // 200/1000 * 800 (width for right)
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('denormalizeArgs leaves non-coord tools untouched', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
const args: any = { device: 'd1', direction: 'down' };
|
||||
mod.denormalizeArgs('mobile_press_button', args, 800, 600);
|
||||
expect(args).toEqual({ device: 'd1', direction: 'down' });
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('denormalizeArgs ignores missing coord fields', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
const args: any = { device: 'd1' };
|
||||
mod.denormalizeArgs(
|
||||
'mobile_click_on_screen_at_coordinates',
|
||||
args,
|
||||
800,
|
||||
600,
|
||||
);
|
||||
expect(args.x).toBeUndefined();
|
||||
expect(args.y).toBeUndefined();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Output normalization tests ──
|
||||
|
||||
test('normalizeElementResult rewrites element coordinates', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
const elements = [
|
||||
{
|
||||
type: 'button',
|
||||
coordinates: { x: 400, y: 300, width: 80, height: 60 },
|
||||
},
|
||||
];
|
||||
const input = 'Found these elements on screen: ' + JSON.stringify(elements);
|
||||
const result = mod.normalizeElementResult(
|
||||
'mobile_list_elements_on_screen',
|
||||
input,
|
||||
800,
|
||||
600,
|
||||
);
|
||||
const parsed = JSON.parse(
|
||||
result.replace('Found these elements on screen: ', ''),
|
||||
);
|
||||
expect(parsed[0].coordinates.x).toBe(500); // 400/800*1000
|
||||
expect(parsed[0].coordinates.y).toBe(500); // 300/600*1000
|
||||
expect(parsed[0].coordinates.width).toBe(100); // 80/800*1000
|
||||
expect(parsed[0].coordinates.height).toBe(100); // 60/600*1000
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeScreenSizeResult rewrites to scale', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
const result = mod.normalizeScreenSizeResult(
|
||||
'mobile_get_screen_size',
|
||||
'Screen size is 1080x2400 pixels',
|
||||
);
|
||||
expect(result).toContain('1000x1000');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeScreenSizeResult leaves non-screen-size tools alone', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
const result = mod.normalizeScreenSizeResult(
|
||||
'mobile_tap',
|
||||
'Tapped at 100, 200',
|
||||
);
|
||||
expect(result).toBe('Tapped at 100, 200');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Screen size cache tests ──
|
||||
|
||||
test('screen size cache round-trip', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
mod.cacheScreenSize('test-device', 1080, 2400);
|
||||
const size = mod.getCachedScreenSize('test-device');
|
||||
expect(size).toEqual({ width: 1080, height: 2400 });
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('screen size cache returns undefined for unknown device', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
expect(mod.getCachedScreenSize('nonexistent')).toBeUndefined();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('ingestScreenSizeFromResult parses response', async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
mod.ingestScreenSizeFromResult('dev1', 'Screen size is 1080x2400 pixels');
|
||||
const size = mod.getCachedScreenSize('dev1');
|
||||
expect(size).toEqual({ width: 1080, height: 2400 });
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Config tests ──
|
||||
|
||||
test('isNormalized returns false by default', async () => {
|
||||
const { mod, restore } = await loadCoordNorm({
|
||||
MOBILE_MCP_COORDINATE_SPACE: undefined,
|
||||
});
|
||||
try {
|
||||
expect(mod.isNormalized()).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('isNormalized returns true when MOBILE_MCP_COORDINATE_SPACE=1', async () => {
|
||||
const { mod, restore } = await loadCoordNorm({
|
||||
MOBILE_MCP_COORDINATE_SPACE: '1',
|
||||
});
|
||||
try {
|
||||
expect(mod.isNormalized()).toBe(true);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('coordinateScale defaults to 1000', async () => {
|
||||
const { mod, restore } = await loadCoordNorm({
|
||||
MOBILE_MCP_COORDINATE_SCALE: undefined,
|
||||
});
|
||||
try {
|
||||
expect(mod.coordinateScale()).toBe(1000);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('coordinateScale reads custom value', async () => {
|
||||
const { mod, restore } = await loadCoordNorm({
|
||||
MOBILE_MCP_COORDINATE_SCALE: '999',
|
||||
});
|
||||
try {
|
||||
expect(mod.coordinateScale()).toBe(999);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Description rewriting tests ──
|
||||
|
||||
test("rewriteDescription replaces 'in pixels' with normalized wording", async () => {
|
||||
const { mod, restore } = await loadCoordNorm();
|
||||
try {
|
||||
const result = mod.rewriteDescription(
|
||||
'Click on the screen at x,y in pixels',
|
||||
);
|
||||
expect(result).toContain('0-1000 normalized coordinates');
|
||||
expect(result).not.toContain('in pixels');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('coordParamDesc is passthrough when not normalized', async () => {
|
||||
const { mod, restore } = await loadCoordNorm({
|
||||
MOBILE_MCP_COORDINATE_SPACE: '0',
|
||||
});
|
||||
try {
|
||||
const desc = 'The x coordinate, in pixels';
|
||||
expect(mod.coordParamDesc(desc)).toBe(desc);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('coordParamDesc rewrites when normalized', async () => {
|
||||
const { mod, restore } = await loadCoordNorm({
|
||||
MOBILE_MCP_COORDINATE_SPACE: '1',
|
||||
});
|
||||
try {
|
||||
const result = mod.coordParamDesc('The x coordinate, in pixels');
|
||||
expect(result).toContain('0-1000 normalized');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
33
packages/mobile-mcp/test/ios.ts
Normal file
33
packages/mobile-mcp/test/ios.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
import { IosManager, IosRobot } from "../src/ios";
|
||||
import { PNG } from "../src/png";
|
||||
|
||||
test.describe("ios", () => {
|
||||
|
||||
let robot: IosRobot;
|
||||
let hasOneDevice = false;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const manager = new IosManager();
|
||||
const devices = await manager.listDevices();
|
||||
hasOneDevice = devices.length === 1;
|
||||
robot = new IosRobot(devices?.[0]?.deviceId || "");
|
||||
});
|
||||
|
||||
test("should be able to get screenshot", async () => {
|
||||
test.skip(!hasOneDevice, "requires exactly one ios device");
|
||||
const screenshot = await robot.getScreenshot();
|
||||
// an black screenshot (screen is off) still consumes over 30KB
|
||||
expect(screenshot.length).toBeGreaterThan(128 * 1024);
|
||||
|
||||
// must be a valid png image that matches the screen size
|
||||
const image = new PNG(screenshot);
|
||||
const pngSize = image.getDimensions();
|
||||
const screenSize = await robot.getScreenSize();
|
||||
|
||||
// wda returns screen size as points, round up
|
||||
expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width);
|
||||
expect(Math.ceil(pngSize.height / screenSize.scale)).toBe(screenSize.height);
|
||||
});
|
||||
});
|
||||
189
packages/mobile-mcp/test/iphone-simulator.ts
Normal file
189
packages/mobile-mcp/test/iphone-simulator.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { PNG } from "../src/png";
|
||||
import { MobileDevice } from "../src/mobile-device";
|
||||
import { Mobilecli } from "../src/mobilecli";
|
||||
|
||||
test.describe("iphone-simulator", () => {
|
||||
|
||||
const mobilecli = new Mobilecli();
|
||||
const devicesResponse = mobilecli.getDevices({
|
||||
platform: "ios",
|
||||
type: "simulator",
|
||||
includeOffline: false,
|
||||
});
|
||||
|
||||
const bootedSimulators = devicesResponse.data.devices;
|
||||
const hasOneSimulator = bootedSimulators.length >= 1;
|
||||
const device = new MobileDevice(bootedSimulators?.[0]?.id || "");
|
||||
|
||||
const restartApp = async (app: string) => {
|
||||
await device.launchApp(app);
|
||||
await device.terminateApp(app);
|
||||
await device.launchApp(app);
|
||||
};
|
||||
|
||||
const restartPreferencesApp = async () => {
|
||||
await restartApp("com.apple.Preferences");
|
||||
};
|
||||
|
||||
const restartRemindersApp = async () => {
|
||||
await restartApp("com.apple.reminders");
|
||||
};
|
||||
|
||||
test("should be able to swipe", async () => {
|
||||
test.skip(!hasOneSimulator, "requires a booted ios simulator");
|
||||
await restartPreferencesApp();
|
||||
|
||||
// make sure "General" is present (since it's at the top of the list)
|
||||
const elements1 = await device.getElementsOnScreen();
|
||||
expect(elements1.findIndex(e => e.name === "com.apple.settings.general")).not.toBe(-1);
|
||||
|
||||
// swipe up (bottom of screen to top of screen)
|
||||
await device.swipe("up");
|
||||
|
||||
// make sure "General" is not visible now
|
||||
const elements2 = await device.getElementsOnScreen();
|
||||
expect(elements2.findIndex(e => e.name === "com.apple.settings.general")).toBe(-1);
|
||||
|
||||
// swipe down
|
||||
await device.swipe("down");
|
||||
|
||||
// make sure "General" is visible again
|
||||
const elements3 = await device.getElementsOnScreen();
|
||||
expect(elements3.findIndex(e => e.name === "com.apple.settings.general")).not.toBe(-1);
|
||||
});
|
||||
|
||||
test("should be able to send keys and press enter", async () => {
|
||||
test.skip(!hasOneSimulator, "requires a booted ios simulator");
|
||||
await restartRemindersApp();
|
||||
|
||||
// find new reminder element
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
const elements = await device.getElementsOnScreen();
|
||||
const newElement = elements.find(e => e.label === "New Reminder");
|
||||
expect(newElement, "should have found New Reminder element").toBeDefined();
|
||||
|
||||
// click on new reminder
|
||||
await device.tap(newElement.rect.x, newElement.rect.y);
|
||||
|
||||
// wait for keyboard to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// send keys with press button "Enter"
|
||||
const random1 = randomBytes(8).toString("hex");
|
||||
await device.sendKeys(random1);
|
||||
await device.pressButton("ENTER");
|
||||
|
||||
// send keys with "\n"
|
||||
const random2 = randomBytes(8).toString("hex");
|
||||
await device.sendKeys(random2 + "\n");
|
||||
|
||||
const elements2 = await device.getElementsOnScreen();
|
||||
expect(elements2.findIndex(e => e.value === random1)).not.toBe(-1);
|
||||
expect(elements2.findIndex(e => e.value === random2)).not.toBe(-1);
|
||||
});
|
||||
|
||||
test("should be able to get the screen size", async () => {
|
||||
test.skip(!hasOneSimulator, "requires a booted ios simulator");
|
||||
const screenSize = await device.getScreenSize();
|
||||
expect(screenSize.width).toBeGreaterThan(256);
|
||||
expect(screenSize.height).toBeGreaterThan(256);
|
||||
expect(screenSize.scale).toBeGreaterThanOrEqual(1);
|
||||
expect(Object.keys(screenSize).length, "screenSize should have exactly 3 properties").toBe(3);
|
||||
});
|
||||
|
||||
test("should be able to get screenshot", async () => {
|
||||
test.skip(!hasOneSimulator, "requires a booted ios simulator");
|
||||
const screenshot = await device.getScreenshot();
|
||||
expect(screenshot.length).toBeGreaterThan(64 * 1024);
|
||||
|
||||
// must be a valid png image that matches the screen size
|
||||
const image = new PNG(screenshot);
|
||||
const pngSize = image.getDimensions();
|
||||
const screenSize = await device.getScreenSize();
|
||||
|
||||
// wda returns screen size as points, round up
|
||||
expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width);
|
||||
expect(Math.ceil(pngSize.height / screenSize.scale)).toBe(screenSize.height);
|
||||
});
|
||||
|
||||
test("should be able to open url", async () => {
|
||||
test.skip(!hasOneSimulator, "requires a booted ios simulator");
|
||||
// simply checking thato openurl with https:// launches safari
|
||||
await device.openUrl("https://www.example.com");
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const elements = await device.getElementsOnScreen();
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
|
||||
const addressBar = elements.find(element => element.type === "TextField" && element.name === "TabBarItemTitle" && element.label === "Address");
|
||||
expect(addressBar, "should have address bar").toBeDefined();
|
||||
});
|
||||
|
||||
test("should be able to list apps", async () => {
|
||||
test.skip(!hasOneSimulator, "requires a booted ios simulator");
|
||||
const apps = await device.listApps();
|
||||
const packages = apps.map(app => app.packageName);
|
||||
expect(packages).toContain("com.apple.mobilesafari");
|
||||
expect(packages).toContain("com.apple.reminders");
|
||||
expect(packages).toContain("com.apple.Preferences");
|
||||
});
|
||||
|
||||
test("should be able to get elements on screen", async () => {
|
||||
test.skip(!hasOneSimulator, "requires a booted ios simulator");
|
||||
await device.pressButton("HOME");
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
const elements = await device.getElementsOnScreen();
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
|
||||
// must have News app in home screen
|
||||
const element = elements.find(e => e.type === "Icon" && e.label === "News");
|
||||
expect(element, "should have News app in home screen").toBeDefined();
|
||||
});
|
||||
|
||||
test("should be able to launch and terminate app", async () => {
|
||||
test.skip(!hasOneSimulator, "requires a booted ios simulator");
|
||||
await restartPreferencesApp();
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
const elements = await device.getElementsOnScreen();
|
||||
|
||||
const buttons = elements.filter(e => e.type === "Button").map(e => e.label);
|
||||
expect(buttons).toContain("General");
|
||||
expect(buttons).toContain("Accessibility");
|
||||
|
||||
// make sure app is terminated
|
||||
await device.terminateApp("com.apple.Preferences");
|
||||
const elements2 = await device.getElementsOnScreen();
|
||||
const buttons2 = elements2.filter(e => e.type === "Button").map(e => e.label);
|
||||
expect(buttons2).not.toContain("General");
|
||||
});
|
||||
|
||||
/*
|
||||
test("should be able to get and set orientation", async () => {
|
||||
test.skip(!hasOneSimulator, "requires a booted ios simulator");
|
||||
|
||||
// Set to portrait and verify
|
||||
await device.setOrientation("portrait");
|
||||
const portrait = await device.getOrientation();
|
||||
expect(portrait).toBe("portrait");
|
||||
|
||||
// Set to landscape and verify
|
||||
await device.setOrientation("landscape");
|
||||
const landscape = await device.getOrientation();
|
||||
expect(landscape).toBe("landscape");
|
||||
|
||||
// Return to portrait
|
||||
await device.setOrientation("portrait");
|
||||
const portraitAgain = await device.getOrientation();
|
||||
expect(portraitAgain).toBe("portrait");
|
||||
});
|
||||
*/
|
||||
|
||||
test("should throw an error if button is not supported", async () => {
|
||||
test.skip(!hasOneSimulator, "requires a booted ios simulator");
|
||||
await expect(device.pressButton("NOT_A_BUTTON" as any)).rejects.toThrow("unsupported button: NOT_A_BUTTON");
|
||||
});
|
||||
});
|
||||
119
packages/mobile-mcp/test/mobilecli.test.ts
Normal file
119
packages/mobile-mcp/test/mobilecli.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { Mobilecli } from "../src/mobilecli";
|
||||
|
||||
type ExecuteCommandCall = {
|
||||
args: string[];
|
||||
};
|
||||
|
||||
function createMockMobilecli(mockResponse: string): { mobilecli: Mobilecli; calls: ExecuteCommandCall[] } {
|
||||
const mobilecli = new Mobilecli();
|
||||
const calls: ExecuteCommandCall[] = [];
|
||||
|
||||
mobilecli.executeCommand = function(args: string[]): string {
|
||||
calls.push({ args });
|
||||
return mockResponse;
|
||||
};
|
||||
|
||||
return { mobilecli, calls };
|
||||
}
|
||||
|
||||
test.describe("mobilecli", () => {
|
||||
|
||||
const mobilecli = new Mobilecli();
|
||||
|
||||
test.describe("getVersion", () => {
|
||||
test("should return a version string", () => {
|
||||
const version = mobilecli.getVersion();
|
||||
expect(version.length).toBeGreaterThan(0);
|
||||
expect(version).not.toContain("failed");
|
||||
});
|
||||
|
||||
test("should return version in correct format", () => {
|
||||
const version = mobilecli.getVersion();
|
||||
// Version should be in format like "0.0.45" or similar
|
||||
const versionPattern = /^\d+\.\d+\.\d+/;
|
||||
expect(version, `Version "${version}" should match pattern X.Y.Z`).toMatch(versionPattern);
|
||||
});
|
||||
|
||||
test("should return failed when MOBILECLI_PATH points to invalid location", () => {
|
||||
try {
|
||||
process.env.MOBILECLI_PATH = "/tmp";
|
||||
const mobilecli = new Mobilecli();
|
||||
const version = mobilecli.getVersion();
|
||||
expect(version, `Expected version to include "failed" but got: ${version}`).toContain("failed");
|
||||
} finally {
|
||||
delete process.env.MOBILECLI_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
test("should call executeCommand with --version argument", () => {
|
||||
const { mobilecli, calls } = createMockMobilecli("mobilecli version 1.0.0");
|
||||
const version = mobilecli.getVersion();
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].args).toEqual(["--version"]);
|
||||
expect(version).toBe("1.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("getDevices", () => {
|
||||
const mockDevicesResponse = JSON.stringify({
|
||||
status: "ok",
|
||||
data: {
|
||||
devices: [
|
||||
{
|
||||
id: "device1",
|
||||
name: "Test Device",
|
||||
platform: "ios",
|
||||
type: "simulator",
|
||||
version: "17.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
test("should call executeCommand with devices argument when no options", () => {
|
||||
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
|
||||
mobilecli.getDevices();
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].args).toEqual(["devices"]);
|
||||
});
|
||||
|
||||
test("should call executeCommand with platform filter", () => {
|
||||
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
|
||||
mobilecli.getDevices({ platform: "ios" });
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].args).toEqual(["devices", "--platform", "ios"]);
|
||||
});
|
||||
|
||||
test("should call executeCommand with type filter", () => {
|
||||
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
|
||||
mobilecli.getDevices({ type: "simulator" });
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].args).toEqual(["devices", "--type", "simulator"]);
|
||||
});
|
||||
|
||||
test("should call executeCommand with includeOffline flag", () => {
|
||||
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
|
||||
mobilecli.getDevices({ includeOffline: true });
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].args).toEqual(["devices", "--include-offline"]);
|
||||
});
|
||||
|
||||
test("should call executeCommand with combined options", () => {
|
||||
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
|
||||
mobilecli.getDevices({
|
||||
platform: "android",
|
||||
type: "emulator",
|
||||
includeOffline: true
|
||||
});
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].args).toEqual(["devices", "--include-offline", "--platform", "android", "--type", "emulator"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
18
packages/mobile-mcp/test/png.ts
Normal file
18
packages/mobile-mcp/test/png.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { PNG } from "../src/png";
|
||||
|
||||
|
||||
test.describe("png", () => {
|
||||
test("should be able to parse png", () => {
|
||||
const buffer = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=";
|
||||
const png = new PNG(Buffer.from(buffer, "base64"));
|
||||
expect(png.getDimensions().width).toBe(1);
|
||||
expect(png.getDimensions().height).toBe(1);
|
||||
});
|
||||
|
||||
test("should be able to detect an invalid png", () => {
|
||||
const buffer = btoa("IAMADUCKIAMADUCKIAMADUCKIAMADUCKIAMADUCK");
|
||||
const png = new PNG(Buffer.from(buffer, "base64"));
|
||||
expect(() => png.getDimensions()).toThrow();
|
||||
});
|
||||
});
|
||||
14
packages/mobile-mcp/tsconfig.json
Normal file
14
packages/mobile-mcp/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"module": "CommonJS",
|
||||
"outDir": "./lib"
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
],
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue