From 7c33224520088d067304c98f7a095ad5dc4abb45 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 3 Jul 2026 01:31:25 +0800 Subject: [PATCH 1/7] Squashed 'packages/mobile-mcp/' content from commit c5d7d27fd git-subtree-dir: packages/mobile-mcp git-subtree-split: c5d7d27fd61e4762e15ae4b1c68b6c011be88bb7 --- .c8rc.json | 6 + .editorconfig | 9 + .github/ISSUE_TEMPLATE/bug_report.md | 30 + .github/workflows/build.yml | 69 + .gitignore | 7 + .husky/pre-commit | 1 + .mocharc.yml | 1 + .npmignore | 5 + CHANGELOG.md | 240 + CODE_OF_CONDUCT.md | 128 + CONTRIBUTING.md | 48 + LICENSE | 201 + README.md | 505 +++ SECURITY.md | 26 + eslint.config.mjs | 161 + mobile-mcp.png | Bin 0 -> 97981 bytes package-lock.json | 6261 ++++++++++++++++++++++++++ package.json | 68 + playwright.config.ts | 17 + server.json | 21 + src/android.ts | 611 +++ src/image-utils.ts | 164 + src/index.ts | 132 + src/ios.ts | 304 ++ src/iphone-simulator.ts | 283 ++ src/logger.ts | 21 + src/mobile-device.ts | 221 + src/mobilecli.ts | 207 + src/png.ts | 20 + src/robot.ts | 147 + src/server.ts | 844 ++++ src/utils.ts | 88 + src/webdriver-agent.ts | 454 ++ test/android.ts | 139 + test/ios.ts | 33 + test/iphone-simulator.ts | 189 + test/mobilecli.test.ts | 119 + test/png.ts | 18 + tsconfig.json | 14 + 39 files changed, 11812 insertions(+) create mode 100644 .c8rc.json create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100755 .husky/pre-commit create mode 100644 .mocharc.yml create mode 100644 .npmignore create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 eslint.config.mjs create mode 100644 mobile-mcp.png create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 server.json create mode 100644 src/android.ts create mode 100644 src/image-utils.ts create mode 100644 src/index.ts create mode 100644 src/ios.ts create mode 100644 src/iphone-simulator.ts create mode 100644 src/logger.ts create mode 100644 src/mobile-device.ts create mode 100644 src/mobilecli.ts create mode 100644 src/png.ts create mode 100644 src/robot.ts create mode 100644 src/server.ts create mode 100644 src/utils.ts create mode 100644 src/webdriver-agent.ts create mode 100644 test/android.ts create mode 100644 test/ios.ts create mode 100644 test/iphone-simulator.ts create mode 100644 test/mobilecli.test.ts create mode 100644 test/png.ts create mode 100644 tsconfig.json diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 00000000000..fcf53a557b9 --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,6 @@ +{ + "include": ["lib/**/*.js"], + "exclude": ["node_modules/**", "lib/**/*.js.map"], + "reporter": ["html", "text"], + "temp-directory": "./coverage/tmp" +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..862dd178fec --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +[*] +indent_style = tab +indent_size = 8 +tab_width = 8 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 150 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000000..a2c93cdbd95 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Configuration (please complete the following information):** +- Agent: [e.g, Claude Desktop, Cursor] +- OS: [e.g, Mac, Linux, Windows] +- Device used: [e.g. Android, iOS, iOS Simulator] +- Device version: [e.g, 18.3.2] +- Device model: [e.g., Samsung Galaxy S25] + +**To Reproduce** +Steps to reproduce the behavior: +1. Use prompt '...' +2. Then do '...' +3. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000000..f19b6695d10 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,69 @@ +name: Build +permissions: + id-token: write + contents: read + +on: + push: + branches: + - main + tags: + - "*.*.*" + pull_request: + branches: + - main + +jobs: + build: + permissions: + id-token: write + contents: read + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Use Node.js 24 + uses: actions/setup-node@v5 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Audit + run: npm audit --audit-level high --omit dev + + - name: Lint + run: npm run lint + + - name: Update version + if: github.ref_type == 'tag' + env: + VERSION: ${{ github.ref_name }} + run: | + npm version "$VERSION" --no-git-tag-version + + - name: Build + run: | + npm run build + + - name: Publish + if: github.ref_type == 'tag' + run: | + npm publish --access public --provenance + + - name: Publish on github mcp registry + if: github.ref_type == 'tag' + env: + VERSION: ${{ github.ref_name }} + run: | + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + brew install mcp-publisher + mcp-publisher login github-oidc + # patch version + sed -i "s/{{VERSION}}/$VERSION/g" server.json + mcp-publisher publish + mcp-publisher logout diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000000..85b563feaf2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/.DS_Store +node_modules +lib +.vscode +coverage +test-results +playwright-report \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000000..3867a0feb36 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run lint diff --git a/.mocharc.yml b/.mocharc.yml new file mode 100644 index 00000000000..25887e2d023 --- /dev/null +++ b/.mocharc.yml @@ -0,0 +1 @@ +timeout: 60s diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000000..6ed45e43653 --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +**/* +README.md +LICENSE +!index.* +!lib/**/*.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000000..f8d3314e2fd --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..f5e149ec84e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +support@mobilenexthq.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..39e76a47dd8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to Mobile MCP + +Thanks for your interest in contributing! This document covers the basics for getting set up and submitting changes. + +## Code of Conduct + +This project adheres to the [Contributor Covenant](CODE_OF_CONDUCT.md). By participating, you agree to keep the community welcoming and respectful. + +## Reporting bugs and requesting features + +- Search [existing issues](https://github.com/mobile-next/mobile-mcp/issues) before opening a new one. +- Open one issue per bug or feature request. +- For bugs, include: mobile-mcp version, OS, target platform (iOS/Android), device/simulator/emulator, a minimal reproduction, and the actual vs. expected behavior. +- For larger feature proposals, please open an issue to discuss the design before sending a PR. + +## Reporting security issues + +Please **do not** file public GitHub issues for security vulnerabilities — see [SECURITY.md](SECURITY.md) for the private reporting process. + +## Development setup + +Requirements: Node.js 22+, and Xcode command line tools / Android Platform Tools if you plan to exercise a simulator, emulator, or real device. + +```bash +git clone https://github.com/mobile-next/mobile-mcp.git +cd mobile-mcp +npm ci +npm run build + +# lint +npm run lint + +# tests (drives a real simulator/emulator/device via Playwright + c8 coverage) +npm test +``` + +`npm run watch` recompiles on change during development. A pre-commit hook (husky) runs `npm run lint` automatically. + +## Submitting changes + +- Branch off `main` and open a pull request when ready. +- Keep PRs focused — one concern per PR. +- Run `npm run build`, `npm run lint`, and `npm test` locally before pushing. +- CI (`.github/workflows/build.yml`) runs an npm audit, lint, and build on every PR — make sure it's green. + +## License + +By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000000..261eeb9e9f8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 00000000000..6119d4153f6 --- /dev/null +++ b/README.md @@ -0,0 +1,505 @@ +# Mobile Next - MCP server for Mobile Development and Automation | iOS, Android, Simulator, Emulator, and Real Devices + +This is a [Model Context Protocol (MCP) server](https://github.com/modelcontextprotocol) that enables scalable mobile automation, development through a platform-agnostic interface, eliminating the need for distinct iOS or Android knowledge. You can run it on emulators, simulators, and real devices (iOS and Android). +This server allows Agents and LLMs to interact with native iOS/Android applications and devices through structured accessibility snapshots or coordinate-based taps based on screenshots. + +

+ + Mobile Next Stars + + + npm + + + + + + Install in VS Code + + + wiki + + + join on Slack + +

+ + +https://github.com/user-attachments/assets/bb084777-beb3-4930-ae6f-8d3fe694ddde + + +

+ + mobile-mcp + +

+ +### 🚀 Mobile MCP Roadmap: Building the Future of Mobile + +Join us on our journey as we continuously enhance Mobile MCP! +Check out our detailed roadmap to see upcoming features, improvements, and milestones. Your feedback is invaluable in shaping the future of mobile automation. + +👉 [Explore the Roadmap](https://github.com/orgs/mobile-next/projects/3) + + +### Main use cases + +How we help to scale mobile automation: + +- 📲 Native app automation (iOS and Android) for testing or data-entry scenarios. +- 📝 Scripted flows and form interactions without manually controlling simulators/emulators or real devices (iPhone, Samsung, Google Pixel etc) +- 🧭 Automating multi-step user journeys driven by an LLM +- 👆 General-purpose mobile application interaction for agent-based frameworks +- 🤖 Enables agent-to-agent communication for mobile automation usecases, data extraction + +## Main Features + +- 🚀 **Fast and lightweight**: Uses native accessibility trees for most interactions, or screenshot based coordinates where a11y labels are not available. +- 🤖 **LLM-friendly**: No computer vision model required in Accessibility (Snapshot). +- 🧿 **Visual Sense**: Evaluates and analyses what's actually rendered on screen to decide the next action. If accessibility data or view-hierarchy coordinates are unavailable, it falls back to screenshot-based analysis. +- 📊 **Deterministic tool application**: Reduces ambiguity found in purely screenshot-based approaches by relying on structured data whenever possible. +- 📺 **Extract structured data**: Enables you to extract structred data from anything visible on screen. + +### 🎯 Platform Support + +| Platform | Supported | +|----------|:---------:| +| iOS Real Device | ✅ | +| iOS Simulator | ✅ | +| Android Real Device | ✅ | +| Android Emulator | ✅ | + +## 🔧 Available MCP Tools + +
+📱 Click to expand tool list - List of Mobile MCP tools for automation and development + +> For detailed implementation and parameter specifications, see [`src/server.ts`](src/server.ts) + +### 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 in pixels +- **`mobile_get_orientation`** - Get the current screen orientation of the device +- **`mobile_set_orientation`** - Change the screen orientation (portrait/landscape) + +### App Management +- **`mobile_list_apps`** - List all installed apps on the device +- **`mobile_launch_app`** - Launch an app using its package name +- **`mobile_terminate_app`** - Stop and terminate a running app +- **`mobile_install_app`** - Install an app from file (.apk, .ipa, .app, .zip) +- **`mobile_uninstall_app`** - Uninstall an app using bundle ID or package name + +### Screen Interaction +- **`mobile_take_screenshot`** - Take a screenshot to understand what's on screen +- **`mobile_save_screenshot`** - Save a screenshot to a file +- **`mobile_list_elements_on_screen`** - List UI elements with their coordinates and properties +- **`mobile_click_on_screen_at_coordinates`** - Click at specific x,y coordinates +- **`mobile_double_tap_on_screen`** - Double-tap at specific coordinates +- **`mobile_long_press_on_screen_at_coordinates`** - Long press at specific coordinates +- **`mobile_swipe_on_screen`** - Swipe in any direction (up, down, left, right) + +### Input & Navigation +- **`mobile_type_keys`** - Type text into focused elements with optional submit +- **`mobile_press_button`** - Press device buttons (HOME, BACK, VOLUME_UP/DOWN, ENTER, etc.) +- **`mobile_open_url`** - Open URLs in the device browser + +### Platform Support +- **iOS**: Simulators and real devices via native accessibility and WebDriverAgent +- **Android**: Emulators and real devices via ADB and UI Automator +- **Cross-platform**: Unified API works across both iOS and Android + +
+ +## 🏗️ Mobile MCP Architecture + +

+ + mobile-mcp + +

+ + +## 📚 Wiki page + +More details in our [wiki page](https://github.com/mobile-next/mobile-mcp/wiki) for setup, configuration and debugging related questions. + + +## Installation and configuration + +**Standard config** works in most of the tools: + +```json +{ + "mcpServers": { + "mobile-mcp": { + "command": "npx", + "args": ["-y", "@mobilenext/mobile-mcp@latest"] + } + } +} +``` + +
+Amp + +Add via the Amp VS Code extension settings screen or by updating your `settings.json` file: + +```json +"amp.mcpServers": { + "mobile-mcp": { + "command": "npx", + "args": [ + "@mobilenext/mobile-mcp@latest" + ] + } +} +``` + +**Amp CLI:** + +Run the following command in your terminal: + +```bash +amp mcp add mobile-mcp -- npx @mobilenext/mobile-mcp@latest +``` + +
+ +
+Cline + +To setup Cline, just add the json above to your MCP settings file. + +[More in our wiki](https://github.com/mobile-next/mobile-mcp/wiki/Cline) + +
+ +
+Claude Code + +Use the Claude Code CLI to add the Mobile MCP server: + +```bash +claude mcp add mobile-mcp -- npx -y @mobilenext/mobile-mcp@latest +``` +
+ +
+Claude Desktop + +Follow the [MCP install guide](https://modelcontextprotocol.io/quickstart/user), use json configuration above. + +
+ +
+Codex + +Use the Codex CLI to add the Mobile MCP server: + +```bash +codex mcp add mobile-mcp npx "@mobilenext/mobile-mcp@latest" +``` + +Alternatively, create or edit the configuration file `~/.codex/config.toml` and add: + +```toml +[mcp_servers.mobile-mcp] +command = "npx" +args = ["@mobilenext/mobile-mcp@latest"] +``` + +For more information, see the Codex MCP documentation. + +
+ +
+Copilot + +Use the Copilot CLI to interactively add the Mobile MCP server: + +```text +/mcp add +``` + +You can edit the configuration file `~/.copilot/mcp-config.json` and add: + +```json +{ + "mcpServers": { + "mobile-mcp": { + "type": "local", + "command": "npx", + "tools": [ + "*" + ], + "args": [ + "@mobilenext/mobile-mcp@latest" + ] + } + } +} +``` + +For more information, see the Copilot CLI documentation. + +
+ +
+Cursor + +#### Click the button to install: + +[Install in Cursor](https://cursor.com/en/install-mcp?name=Mobile%20MCP&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBtb2JpbGVuZXh0L21vYmlsZS1tY3BAbGF0ZXN0Il19) + +#### Or install manually: + +Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx -y @mobilenext/mobile-mcp@latest`. You can also verify config or add command like arguments via clicking `Edit`. + +
+ +
+Gemini CLI + +Use the Gemini CLI to add the Mobile MCP server: + +```bash +gemini mcp add mobile-mcp npx -y @mobilenext/mobile-mcp@latest +``` + +
+ +
+Goose + +#### Click the button to install: + +[![Install in Goose](https://block.github.io/goose/img/extension-install-dark.svg)](https://block.github.io/goose/extension?cmd=npx&arg=-y&arg=%40mobilenext%2Fmobile-mcp%40latest&id=mobile-mcp&name=Mobile%20MCP&description=Mobile%20automation%20and%20development%20for%20iOS%2C%20Android%2C%20simulators%2C%20emulators%2C%20and%20real%20devices) + +#### Or install manually: + +Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx -y @mobilenext/mobile-mcp@latest`. Click "Add Extension". + +
+ +
+Kiro + +Follow the MCP Servers [documentation](https://kiro.dev/docs/mcp/). For example in `.kiro/settings/mcp.json`: + +```json +{ + "mcpServers": { + "mobile-mcp": { + "command": "npx", + "args": [ + "@mobilenext/mobile-mcp@latest" + ] + } + } +} +``` + +
+ +
+opencode + +Follow the MCP Servers documentation. For example in `~/.config/opencode/opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "mobile-mcp": { + "type": "local", + "command": [ + "npx", + "@mobilenext/mobile-mcp@latest" + ], + "enabled": true + } + } +} +``` + +
+ +
+Qodo Gen + +Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the standard config above. + +Click Save. + +
+ + +
+Windsurf + +Open Windsurf settings, navigate to MCP servers, and add a new server using the `command` type with: + +```bash +npx @mobilenext/mobile-mcp@latest +``` + +Or add the standard config under `mcpServers` in your settings as shown above. + +
+ + +[Read more in our wiki](https://github.com/mobile-next/mobile-mcp/wiki)! 🚀 + +### SSE Server Mode + +By default, Mobile MCP runs over stdio. To start an SSE server instead, use the `--listen` flag: + +```bash +npx @mobilenext/mobile-mcp@latest --listen 3000 +``` + +This binds to `localhost:3000`. To bind to a specific interface: + +```bash +npx @mobilenext/mobile-mcp@latest --listen 0.0.0.0:3000 +``` + +Then configure your MCP client to connect to `http://:3000/mcp`. + +#### Authorization + +To require Bearer token authorization on the SSE server, set the `MOBILEMCP_AUTH` environment variable: + +```bash +MOBILEMCP_AUTH=my-secret-token npx @mobilenext/mobile-mcp@latest --listen 3000 +``` + +When set, all requests must include the header `Authorization: Bearer my-secret-token`. + +### 🛠️ How to Use 📝 + +After adding the MCP server to your IDE/Client, you can instruct your AI assistant to use the available tools. +For example, in Cursor's agent mode, you could use the prompts below to quickly validate, test and iterate on UI intereactions, read information from screen, go through complex workflows. +Be descriptive, straight to the point. + +### ✨ Example Prompts + +#### Workflows + +You can specifiy detailed workflows in a single prompt, verify business logic, setup automations. You can go crazy: + +**Search for a video, comment, like and share it.** +``` +Find the video called " Beginner Recipe for Tonkotsu Ramen" by Way of +Ramen, click on like video, after liking write a comment " this was +delicious, will make it next Friday", share the video with the first +contact in your whatsapp list. +``` + +**Download a successful step counter app, register, setup workout and 5-star the app** +``` +Find and Download a free "Pomodoro" app that has more than 1k stars. +Launch the app, register with my email, after registration find how to +start a pomodoro timer. When the pomodoro timer started, go back to the +app store and rate the app 5 stars, and leave a comment how useful the +app is. +``` + +**Search in Substack, read, highlight, comment and save an article** +``` +Open Substack website, search for "Latest trends in AI automation 2025", +open the first article, highlight the section titled "Emerging AI trends", +and save article to reading list for later review, comment a random +paragraph summary. +``` + +**Reserve a workout class, set timer** +``` +Open ClassPass, search for yoga classes tomorrow morning within 2 miles, +book the highest-rated class at 7 AM, confirm reservation, +setup a timer for the booked slot in the phone +``` + +**Find a local event, setup calendar event** +``` +Open Eventbrite, search for AI startup meetup events happening this +weekend in "Austin, TX", select the most popular one, register and RSVP +yes to the event, setup a calendar event as a reminder. +``` + +**Check weather forecast and send a Whatsapp/Telegram/Slack message** +``` +Open Weather app, check tomorrow's weather forecast for "Berlin", and +send the summary via Whatsapp/Telegram/Slack to contact "Lauren Trown", +thumbs up their response. +``` + +- **Schedule a meeting in Zoom and share invite via email** +``` +Open Zoom app, schedule a meeting titled "AI Hackathon" for tomorrow at +10AM with a duration of 1 hour, copy the invitation link, and send it via +Gmail to contacts "team@example.com". +``` +[More prompt examples can be found here.](https://github.com/mobile-next/mobile-mcp/wiki/Prompt-Example-repo-list) + +## Prerequisites + +What you will need to connect MCP with your agent and mobile devices: + +- [Xcode command line tools](https://developer.apple.com/xcode/resources/) +- [Android Platform Tools](https://developer.android.com/tools/releases/platform-tools) +- [node.js](https://nodejs.org/en/download/) v22+ +- [MCP](https://modelcontextprotocol.io/introduction) supported foundational models or agents, like [Claude MCP](https://modelcontextprotocol.io/quickstart/server), [OpenAI Agent SDK](https://openai.github.io/openai-agents-python/mcp/), [Copilot Studio](https://www.microsoft.com/en-us/microsoft-copilot/blog/copilot-studio/introducing-model-context-protocol-mcp-in-copilot-studio-simplified-integration-with-ai-apps-and-agents/) + +### Simulators, Emulators, and Real Devices + +When launched, Mobile MCP can connect to: +- iOS Simulators on macOS/Linux +- Android Emulators on Linux/Windows/macOS +- iOS or Android real devices (requires proper platform tools and drivers) + +Make sure you have your mobile platform SDKs (Xcode, Android SDK) installed and configured properly before running Mobile Next Mobile MCP. + +### Telemetry + +Mobile MCP collects anonymous usage telemetry via PostHog. To disable it, set the `MOBILEMCP_DISABLE_TELEMETRY` environment variable: + +```bash +MOBILEMCP_DISABLE_TELEMETRY=1 npx @mobilenext/mobile-mcp@latest +``` + +For json configurations: + +```json +{ + "mcpServers": { + "mobile-mcp": { + "command": "npx", + "args": ["-y", "@mobilenext/mobile-mcp@latest"], + "env": { + "MOBILEMCP_DISABLE_TELEMETRY": "1" + } + } + } +} +``` + +### Running in "headless" mode on Simulators/Emulators + +When you do not have a real device connected to your machine, you can run Mobile MCP with an emulator or simulator in the background. + +For example, on Android: +1. Start an emulator (avdmanager / emulator command). +2. Run Mobile MCP with the desired flags + +On iOS, you'll need Xcode and to run the Simulator before using Mobile MCP with that simulator instance. +- `xcrun simctl list` +- `xcrun simctl boot "iPhone 16"` + +# Thanks to all contributors ❤️ + +### We appreciate everyone who has helped improve this project. + + + + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..3dca51be2ee --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Supported Versions + +All versions of this project are currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| All | :white_check_mark: | + +## Important notes regarding use of mobile-mcp + +**mobile-mcp** is an MCP server for controlling a mobile device. Device may be a real iOS device, real Android device, an iOS Simulator or Android +Emulator. + +You may use mobile-mcp for various tasks, such as app development, marketing automation, quality assurance and so on. + +As a rule of thumb, verify which tools your Agent is invoking. It has access to the device or simulator, it may operate the device to the security +scope of that device. It is suggested that you use a device that is purposed for this use. + +## Reporting a Vulnerability + +To report a security vulnerability, please join our Slack channel at http://mobilenexthq.com/join-slack and DM the moderators with details of the vulnerability. + +We take all security reports seriously and will respond as quickly as possible. + diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000000..b004623993c --- /dev/null +++ b/eslint.config.mjs @@ -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, + }, +]; diff --git a/mobile-mcp.png b/mobile-mcp.png new file mode 100644 index 0000000000000000000000000000000000000000..b9d29f9e401b06b2440f38f97281fbc522d94a58 GIT binary patch literal 97981 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

g71PUZ-Mj*POWkGPG~hL#2# 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

ODx$o0jP!B<8Y@k$oh^oxxo$}gm*CE+ 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" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mobilewright/core": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@mobilewright/core/-/core-0.0.45.tgz", + "integrity": "sha512-5wtqoG4AOAkr3u+Gegodvt6aeuF2Qe6Y5P0/p1HL8S1lFZ035MCD96TxE0zEDYNVaYyVLIaIAN59ri2O3Vi+JQ==", + "license": "Apache-2.0", + "dependencies": { + "@mobilewright/protocol": "^0.0.45", + "debug": "^4.4.3", + "playwright-core": "1.58.2", + "sharp": "^0.34.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mobilewright/driver-mobilecli": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@mobilewright/driver-mobilecli/-/driver-mobilecli-0.0.45.tgz", + "integrity": "sha512-6v8hyjw3jgP6TeH4yqtKfXphr2sSIZj0BhD013Juk1//AYOdnGdltC2c0kbdeWUdqFB+K3G4Kc32fBVi7Gbfgg==", + "license": "Apache-2.0", + "dependencies": { + "@mobilewright/protocol": "^0.0.45", + "debug": "^4.4.3", + "mobilecli": "0.3.84", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mobilewright/driver-mobilenext": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@mobilewright/driver-mobilenext/-/driver-mobilenext-0.0.45.tgz", + "integrity": "sha512-xXGuAb+ihezvtETr883mIlxreqECjRdXSL8jbFgK4VhJSk1/P+98zWlTMQRMVgwKOg9IKv+GG6JXSkTFD08YKw==", + "license": "Apache-2.0", + "dependencies": { + "@mobilewright/protocol": "^0.0.45", + "debug": "^4.4.3", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mobilewright/protocol": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@mobilewright/protocol/-/protocol-0.0.45.tgz", + "integrity": "sha512-bArKuq4f7woi9ysBiRb1UWxKGHgZ+vwEH8WbpMYXXoEelDTpes0MZfMs4YNWisYSdwl4IRLiHzseQqMkJDPePQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test/node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@playwright/test/node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz", + "integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.13.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/commander": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@types/commander/-/commander-2.12.0.tgz", + "integrity": "sha512-DDmRkovH7jPjnx7HcbSnqKg2JeNANyxNZeUvB0iE+qKBLN+vzN5iSIwt+J2PFSmBuYEut4mgQvI/fTX9YQH/vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.18.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz", + "integrity": "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", + "integrity": "sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.43.0", + "@typescript-eslint/type-utils": "8.43.0", + "@typescript-eslint/utils": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.43.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.43.0.tgz", + "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.43.0", + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.43.0.tgz", + "integrity": "sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.43.0", + "@typescript-eslint/types": "^8.43.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.43.0.tgz", + "integrity": "sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.43.0.tgz", + "integrity": "sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.43.0.tgz", + "integrity": "sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0", + "@typescript-eslint/utils": "8.43.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.43.0.tgz", + "integrity": "sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.43.0.tgz", + "integrity": "sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.43.0", + "@typescript-eslint/tsconfig-utils": "8.43.0", + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/visitor-keys": "8.43.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.43.0.tgz", + "integrity": "sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.43.0", + "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/typescript-estree": "8.43.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.43.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.43.0.tgz", + "integrity": "sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.43.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c8": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^7.0.1", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/c8/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/c8/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/c8/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/c8/node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.35.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin/-/eslint-plugin-1.0.1.tgz", + "integrity": "sha512-ervp8C09On0fLA258TvE08AqAr/bhRYgHVZd3BrJjD4JfOA2JGANDLGs06j51oWqfPd7Feoo3OoqHD+fuI2sFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "requireindex": "~1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-notice": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-notice/-/eslint-plugin-notice-1.0.0.tgz", + "integrity": "sha512-M3VLQMZzZpvfTZ/vy9FmClIKq5rLBbQpM0KgfLZPJPrVXpmJYeobmmb+lfJzHWdNm8PWwvw8KlafQWo2N9xx1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-root": "^1.1.0", + "lodash": "^4.17.21", + "metric-lcs": "^0.1.2" + }, + "peerDependencies": { + "eslint": ">=3.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "dev": true, + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/metric-lcs": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz", + "integrity": "sha512-+TZ5dUDPKPJaU/rscTzxyN8ZkX7eAVLAiQU/e+YINleXPv03SCmJShaMT1If1liTH8OcmWXZs0CmzCBRBLcMpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mobilecli": { + "version": "0.3.84", + "resolved": "https://registry.npmjs.org/mobilecli/-/mobilecli-0.3.84.tgz", + "integrity": "sha512-TOyyavbO5mtzMlJ6vdAZbtjgH4GM392biOxiKDV9s4lMPzA0TSUphBZVCOKsCN03kotNJA2JytdY500H6iHvJw==", + "license": "MIT", + "bin": { + "mobilecli": "index.js" + } + }, + "node_modules/mobilewright": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/mobilewright/-/mobilewright-0.0.45.tgz", + "integrity": "sha512-z9QJRtiGBax310zAhoMBvULm8uKnVPcVn+ELhII1E1LLPJDzoGYhFcVLfAzS8ela99KqYRKFS0D75ayCpENhFQ==", + "license": "Apache-2.0", + "dependencies": { + "@mobilewright/core": "^0.0.45", + "@mobilewright/driver-mobilecli": "^0.0.45", + "@mobilewright/driver-mobilenext": "^0.0.45", + "@mobilewright/protocol": "^0.0.45", + "commander": "^14.0.3", + "debug": "^4.4.3", + "playwright": "1.58.2", + "ws": "^8.18.0" + }, + "bin": { + "mobilewright": "dist/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mobilewright/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requireindex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz", + "integrity": "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.5" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", + "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000000..57ae280fe60 --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "@mobilenext/mobile-mcp", + "mcpName": "io.github.mobile-next/mobile-mcp", + "version": "0.0.1", + "description": "Mobile MCP", + "repository": { + "type": "git", + "url": "git+https://github.com/mobile-next/mobile-mcp.git" + }, + "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": "husky" + }, + "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": "index.js", + "bin": { + "mcp-server-mobile": "lib/index.js" + }, + "directories": { + "lib": "lib" + }, + "author": "", + "bugs": { + "url": "https://github.com/mobile-next/mobile-mcp/issues" + }, + "homepage": "https://github.com/mobile-next/mobile-mcp#readme" +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000000..d07f6b48329 --- /dev/null +++ b/playwright.config.ts @@ -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, +}); diff --git a/server.json b/server.json new file mode 100644 index 00000000000..feeeb5370c1 --- /dev/null +++ b/server.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.mobile-next/mobile-mcp", + "description": "MCP server for iOS and Android Mobile Development, Automation and Testing", + "repository": { + "url": "https://github.com/mobile-next/mobile-mcp", + "source": "github" + }, + "version": "{{VERSION}}", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "@mobilenext/mobile-mcp", + "version": "{{VERSION}}", + "transport": { + "type": "stdio" + } + } + ] +} diff --git a/src/android.ts b/src/android.ts new file mode 100644 index 00000000000..8be2cc790c9 --- /dev/null +++ b/src/android.ts @@ -0,0 +1,611 @@ +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, 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 = { + "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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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.80); + y1 = Math.floor(screenSize.height * 0.20); + break; + case "down": + x0 = x1 = centerX; + y0 = Math.floor(screenSize.height * 0.20); + y1 = Math.floor(screenSize.height * 0.80); + break; + case "left": + x0 = Math.floor(screenSize.width * 0.80); + x1 = Math.floor(screenSize.width * 0.20); + y0 = y1 = Math.floor(screenSize.height * 0.50); + break; + case "right": + x0 = Math.floor(screenSize.width * 0.20); + x1 = Math.floor(screenSize.width * 0.80); + y0 = y1 = Math.floor(screenSize.height * 0.50); + 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 { + 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 { + 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 = []; + + 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 { + const parsedXml = await this.getUiAutomatorXml(); + const hierarchy = parsedXml.hierarchy; + const elements = this.collectElements(hierarchy.node); + return elements; + } + + public async terminateApp(packageName: string): Promise { + validatePackageName(packageName); + this.adb("shell", "am", "force-stop", packageName); + } + + public async installApp(path: string): Promise { + try { + this.adb("install", "-r", 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 { + 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 { + 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 { + const packages = await this.listPackages(); + return packages.includes("com.mobilenext.devicekit"); + } + + public async sendKeys(text: string): Promise { + 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 { + this.adb("shell", "input", "tap", `${x}`, `${y}`); + } + + public async longPress(x: number, y: number, duration: number): Promise { + // 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 { + 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 { + 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 { + const rotation = this.adb("shell", "settings", "get", "system", "user_rotation").toString().trim(); + return rotation === "0" ? "portrait" : "landscape"; + } + + private async getUiAutomatorDump(): Promise { + 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 { + 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 { + 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 []; + } + } +} diff --git a/src/image-utils.ts b/src/image-utils.ts new file mode 100644 index 00000000000..e4ce5453909 --- /dev/null +++ b/src/image-utils.ts @@ -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(); +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000000..d851fdf18fd --- /dev/null +++ b/src/index.ts @@ -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 ", "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(); diff --git a/src/ios.ts b/src/ios.ts new file mode 100644 index 00000000000..382b6d97765 --- /dev/null +++ b/src/ios.ts @@ -0,0 +1,304 @@ +import { Socket } from "node:net"; +import { execFileSync } from "node:child_process"; + +import { WebDriverAgent } from "./webdriver-agent"; +import { ActionableError, Button, InstalledApp, 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 { + 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 { + return await this.isListeningOnPort(IOS_TUNNEL_PORT); + } + + private async isWdaForwardRunning(): Promise { + return await this.isListeningOnPort(WDA_PORT); + } + + private async assertTunnelRunning(): Promise { + 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 { + + 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 { + return execFileSync(getGoIosPath(), ["--udid", this.deviceId, ...args], {}).toString(); + } + + public async getIosVersion(): Promise { + const output = await this.ios("info"); + const json = JSON.parse(output); + return json.ProductVersion; + } + + private async isTunnelRequired(): Promise { + const version = await this.getIosVersion(); + const args = version.split("."); + return parseInt(args[0], 10) >= 17; + } + + public async getScreenSize(): Promise { + const wda = await this.wda(); + return await wda.getScreenSize(); + } + + public async swipe(direction: SwipeDirection): Promise { + const wda = await this.wda(); + await wda.swipe(direction); + } + + public async swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance?: number): Promise { + const wda = await this.wda(); + await wda.swipeFromCoordinate(x, y, direction, distance); + } + + public async listApps(): Promise { + 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 { + 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 { + validatePackageName(packageName); + await this.assertTunnelRunning(); + await this.ios("kill", packageName); + } + + public async installApp(path: string): Promise { + 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 { + 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 { + const wda = await this.wda(); + await wda.openUrl(url); + } + + public async sendKeys(text: string): Promise { + const wda = await this.wda(); + await wda.sendKeys(text); + } + + public async pressButton(button: Button): Promise { + const wda = await this.wda(); + await wda.pressButton(button); + } + + public async tap(x: number, y: number): Promise { + const wda = await this.wda(); + await wda.tap(x, y); + } + + public async doubleTap(x: number, y: number): Promise { + const wda = await this.wda(); + await wda.doubleTap(x, y); + } + + public async longPress(x: number, y: number, duration: number): Promise { + const wda = await this.wda(); + await wda.longPress(x, y, duration); + } + + public async getElementsOnScreen(): Promise { + const wda = await this.wda(); + return await wda.getElementsOnScreen(); + } + + public async getScreenshot(): Promise { + 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 { + const wda = await this.wda(); + await wda.setOrientation(orientation); + } + + public async getOrientation(): Promise { + 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 { + 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; + } +} diff --git a/src/iphone-simulator.ts b/src/iphone-simulator.ts new file mode 100644 index 00000000000..ef95f0e27b8 --- /dev/null +++ b/src/iphone-simulator.ts @@ -0,0 +1,283 @@ +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, 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 { + const apps = await this.listApps(); + return apps.map(app => app.packageName).includes("com.facebook.WebDriverAgentRunner.xctrunner"); + } + + private async startWda(): Promise { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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; + return Object.values(output).map(app => ({ + packageName: app.CFBundleIdentifier, + appName: app.CFBundleDisplayName, + })); + } + + public async getScreenSize(): Promise { + 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 { + const wda = await this.wda(); + return wda.swipe(direction); + } + + public async swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance?: number): Promise { + 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 { + 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 { + const wda = await this.wda(); + return wda.getElementsOnScreen(); + } + + public async setOrientation(orientation: Orientation): Promise { + const wda = await this.wda(); + return wda.setOrientation(orientation); + } + + public async getOrientation(): Promise { + const wda = await this.wda(); + return wda.getOrientation(); + } +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000000..3a760099460 --- /dev/null +++ b/src/logger.ts @@ -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); +}; diff --git a/src/mobile-device.ts b/src/mobile-device.ts new file mode 100644 index 00000000000..b0babaa5f3d --- /dev/null +++ b/src/mobile-device.ts @@ -0,0 +1,221 @@ +import { Mobilecli } from "./mobilecli"; +import { Button, InstalledApp, 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 { + 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 { + 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 { + 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 { + const fullArgs = ["screenshot", "--device", this.deviceId, "--format", "png", "--output", "-"]; + return this.mobilecli.executeCommandBuffer(fullArgs); + } + + public async listApps(): Promise { + 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 { + const args = ["apps", "launch", packageName]; + if (locale) { + args.push("--locale", locale); + } + + this.runCommand(args); + } + + public async terminateApp(packageName: string): Promise { + this.runCommand(["apps", "terminate", packageName]); + } + + public async installApp(path: string): Promise { + this.runCommand(["apps", "install", path]); + } + + public async uninstallApp(bundleId: string): Promise { + this.runCommand(["apps", "uninstall", bundleId]); + } + + public async openUrl(url: string): Promise { + this.runCommand(["url", url]); + } + + public async sendKeys(text: string): Promise { + this.runCommand(["io", "text", text]); + } + + public async pressButton(button: Button): Promise { + this.runCommand(["io", "button", button]); + } + + public async tap(x: number, y: number): Promise { + this.runCommand(["io", "tap", `${x},${y}`]); + } + + public async doubleTap(x: number, y: number): Promise { + // 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 { + this.runCommand(["io", "longpress", `${x},${y}`, "--duration", `${duration}`]); + } + + public async getElementsOnScreen(): Promise { + 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 { + this.runCommand(["device", "orientation", "set", orientation]); + } + + public async getOrientation(): Promise { + const response = JSON.parse(this.runCommand(["device", "orientation", "get"])) as OrientationResponse; + return response.data.orientation; + } +} diff --git a/src/mobilecli.ts b/src/mobilecli.ts new file mode 100644 index 00000000000..f5394f84f8b --- /dev/null +++ b/src/mobilecli.ts @@ -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; + } +} diff --git a/src/png.ts b/src/png.ts new file mode 100644 index 00000000000..dc87a9062ba --- /dev/null +++ b/src/png.ts @@ -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 }; + } +} diff --git a/src/robot.ts b/src/robot.ts new file mode 100644 index 00000000000..59bb344dfc4 --- /dev/null +++ b/src/robot.ts @@ -0,0 +1,147 @@ +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 type Orientation = "portrait" | "landscape"; + +export interface Robot { + /** + * Get the screen size of the device in pixels. + */ + getScreenSize(): Promise; + + /** + * Swipe in a direction. + */ + swipe(direction: SwipeDirection): Promise; + + /** + * Swipe from a specific coordinate in a direction. + */ + swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance?: number): Promise; + + /** + * 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; + + /** + * List all installed apps on the device. Returns an array of package names (or + * bundle identifiers in iOS) for all installed apps. + */ + listApps(): Promise; + + /** + * Launch an app. + */ + launchApp(packageName: string, locale?: string): Promise; + + /** + * Terminate an app. If app was already terminated (or non existent) then this + * is a no-op. + */ + terminateApp(packageName: string): Promise; + + /** + * Install an app on the device from a file path. + */ + installApp(path: string): Promise; + + /** + * Uninstall an app from the device. + */ + uninstallApp(bundleId: string): Promise; + + /** + * 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; + + /** + * Send keys to the device, simulating keyboard input. + */ + sendKeys(text: string): Promise; + + /** + * Press a button on the device, simulating a physical button press. + */ + pressButton(button: Button): Promise; + + /** + * Tap on a specific coordinate on the screen. + */ + tap(x: number, y: number): Promise; + + /** + * Tap on a specific coordinate on the screen. + */ + doubleTap(x: number, y: number): Promise; + + /** + * 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; + + /** + * 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; + + /** + * Change the screen orientation of the device. + * @param orientation The desired orientation ("portrait" or "landscape") + */ + setOrientation(orientation: Orientation): Promise; + + /** + * Get the current screen orientation. + */ + getOrientation(): Promise; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 00000000000..8de6604cf47 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,844 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { ChildProcess } from "node:child_process"; + +import { error, trace } from "./logger"; +import { AndroidRobot, AndroidDeviceManager } from "./android"; +import { ActionableError, Robot } from "./robot"; +import { IosManager, IosRobot } from "./ios"; +import { PNG } from "./png"; +import { isScalingAvailable, Image } from "./image-utils"; +import { Mobilecli } from "./mobilecli"; +import { MobileDevice } from "./mobile-device"; +import { validateOutputPath, validateFileExtension } from "./utils"; + +const ALLOWED_SCREENSHOT_EXTENSIONS = [".png", ".jpg", ".jpeg"]; +const ALLOWED_RECORDING_EXTENSIONS = [".mp4"]; + +interface MobilecliDevice { + id: string; + name: string; + platform: "android" | "ios"; + type: "real" | "emulator" | "simulator"; + version: string; + state: "online" | "offline"; +} + +interface MobilecliDevicesResponse { + devices: MobilecliDevice[]; +} + +interface ActiveRecording { + process: ChildProcess; + outputPath: string; + startedAt: number; +} + +export const getAgentVersion = (): string => { + const json = require("../package.json"); + return json.version; +}; + +export const createMcpServer = (): McpServer => { + + const server = new McpServer({ + name: "mobile-mcp", + version: getAgentVersion(), + }); + + + const getClientName = (): string => { + try { + const clientInfo = server.server.getClientVersion(); + const clientName = clientInfo?.name || "unknown"; + return clientName; + } catch (error: any) { + return "unknown"; + } + }; + + type ZodSchemaShape = Record; + + interface ToolAnnotations { + readOnlyHint?: boolean; + destructiveHint?: boolean; + } + + const tool = (name: string, title: string, description: string, paramsSchema: ZodSchemaShape, annotations: ToolAnnotations, cb: (args: any) => Promise) => { + server.registerTool(name, { + title, + description, + inputSchema: paramsSchema, + annotations, + }, (async (args: any, _extra: any) => { + try { + trace(`Invoking ${name} with args: ${JSON.stringify(args)}`); + const start = +new Date(); + const response = await cb(args); + const duration = +new Date() - start; + trace(`=> ${response}`); + posthog("tool_invoked", { "ToolName": name, "Duration": duration }).then(); + return { + content: [{ type: "text", text: response }], + }; + } catch (error: any) { + posthog("tool_failed", { "ToolName": name }).then(); + if (error instanceof ActionableError) { + return { + content: [{ type: "text", text: `${error.message}. Please fix the issue and try again.` }], + }; + } else { + // a real exception + trace(`Tool '${description}' failed: ${error.message} stack: ${error.stack}`); + return { + content: [{ type: "text", text: `Error: ${error.message}` }], + isError: true, + }; + } + } + }) as any); + }; + + const posthog = async (event: string, properties: Record) => { + if (process.env.MOBILEMCP_DISABLE_TELEMETRY) { + return; + } + + try { + const url = "https://us.i.posthog.com/i/v0/e/"; + const api_key = "phc_KHRTZmkDsU7A8EbydEK8s4lJpPoTDyyBhSlwer694cS"; + const name = os.hostname() + process.execPath; + const distinct_id = crypto.createHash("sha256").update(name).digest("hex"); + const systemProps: any = { + Platform: os.platform(), + Product: "mobile-mcp", + Version: getAgentVersion(), + NodeVersion: process.version, + CI: process.env.CI || "0", + }; + + const clientName = getClientName(); + if (clientName !== "unknown") { + systemProps.AgentName = clientName; + } + + await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + api_key, + event, + properties: { + ...systemProps, + ...properties, + }, + distinct_id, + }) + }); + } catch (err: any) { + // ignore + } + }; + + const mobilecli = new Mobilecli(); + const activeRecordings = new Map(); + const agentVerifiedSimulators = new Set(); + posthog("launch", {}).then(); + + const ensureMobilecliAvailable = (): void => { + try { + const version = mobilecli.getVersion(); + if (version.startsWith("failed")) { + throw new Error("mobilecli version check failed"); + } + } catch (error: any) { + throw new ActionableError(`mobilecli is not available or not working properly. Please review the documentation at https://github.com/mobile-next/mobile-mcp/wiki for installation instructions`); + } + }; + + const getRobotFromDevice = (deviceId: string): Robot => { + + // from now on, we must have mobilecli working + ensureMobilecliAvailable(); + + // Check if it's an iOS device + const iosManager = new IosManager(); + const iosDevices = iosManager.listDevices(); + const iosDevice = iosDevices.find(d => d.deviceId === deviceId); + if (iosDevice) { + posthog("get_robot", { "DevicePlatform": "ios", "DeviceType": "real" }).then(); + return new IosRobot(deviceId); + } + + // Check if it's an Android device + const androidManager = new AndroidDeviceManager(); + const androidDevices = androidManager.getConnectedDevices(); + const androidDevice = androidDevices.find(d => d.deviceId === deviceId); + if (androidDevice) { + posthog("get_robot", { "DevicePlatform": "android" }).then(); + return new AndroidRobot(deviceId); + } + + // Check if it's a simulator (will later replace all other device types as well) + const response = mobilecli.getDevices({ + platform: "ios", + type: "simulator", + includeOffline: false, + }); + + if (response.status === "ok" && response.data && response.data.devices) { + for (const device of response.data.devices) { + if (device.id === deviceId) { + if (!agentVerifiedSimulators.has(deviceId)) { + const agentStatus = mobilecli.agentStatus(deviceId); + if (agentStatus.status === "fail") { + mobilecli.agentInstall(deviceId); + } + + agentVerifiedSimulators.add(deviceId); + } + + posthog("get_robot", { "DevicePlatform": "ios", "DeviceType": "simulator" }).then(); + return new MobileDevice(deviceId); + } + } + } + + throw new ActionableError(`Device "${deviceId}" not found. Use the mobile_list_available_devices tool to see available devices.`); + }; + + tool( + "mobile_list_available_devices", + "List Devices", + "List all available devices. This includes both physical mobile devices and mobile simulators and emulators. It returns both Android and iOS devices.", + {}, + { readOnlyHint: true }, + async ({}) => { + + // from today onward, we must have mobilecli working + ensureMobilecliAvailable(); + + const iosManager = new IosManager(); + const androidManager = new AndroidDeviceManager(); + const devices: MobilecliDevice[] = []; + + // Get Android devices with details + const androidDevices = androidManager.getConnectedDevicesWithDetails(); + for (const device of androidDevices) { + devices.push({ + id: device.deviceId, + name: device.name, + platform: "android", + type: "emulator", + version: device.version, + state: "online", + }); + } + + // Get iOS physical devices with details + try { + const iosDevices = iosManager.listDevicesWithDetails(); + for (const device of iosDevices) { + devices.push({ + id: device.deviceId, + name: device.deviceName, + platform: "ios", + type: "real", + version: device.version, + state: "online", + }); + } + } catch (error: any) { + // If go-ios is not available, silently skip + } + + // Get iOS simulators from mobilecli (excluding offline devices) + const response = mobilecli.getDevices({ + platform: "ios", + type: "simulator", + includeOffline: false, + }); + if (response.status === "ok" && response.data && response.data.devices) { + for (const device of response.data.devices) { + devices.push({ + id: device.id, + name: device.name, + platform: device.platform, + type: device.type, + version: device.version, + state: "online", + }); + } + } + + const out: MobilecliDevicesResponse = { devices }; + return JSON.stringify(out); + } + ); + + if (process.env.MOBILEFLEET_ENABLE === "1") { + tool( + "mobile_list_remote_devices", + "List Remote Devices", + "List devices available in the remote fleet", + {}, + { readOnlyHint: true }, + async ({}) => { + ensureMobilecliAvailable(); + const result = mobilecli.remoteListDevices(); + return result; + } + ); + + tool( + "mobile_allocate_remote_device", + "Allocate Remote Device", + "Reserve a device from the remote fleet", + { + platform: z.enum(["ios", "android"]).describe("The platform to allocate a device for"), + }, + { destructiveHint: true }, + async ({ platform }) => { + ensureMobilecliAvailable(); + const result = mobilecli.remoteAllocate(platform); + return result; + } + ); + + tool( + "mobile_release_remote_device", + "Release Remote Device", + "Release a device back to the remote fleet", + { + device: z.string().describe("The device identifier to release back to the remote fleet"), + }, + { destructiveHint: true }, + async ({ device }) => { + ensureMobilecliAvailable(); + const result = mobilecli.remoteRelease(device); + return result; + } + ); + } + + tool( + "mobile_list_apps", + "List Apps", + "List all the installed apps on the device", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.") + }, + { readOnlyHint: true }, + async ({ device }) => { + const robot = getRobotFromDevice(device); + const result = await robot.listApps(); + return `Found these apps on device: ${result.map(app => `${app.appName} (${app.packageName})`).join(", ")}`; + } + ); + + tool( + "mobile_launch_app", + "Launch App", + "Launch an app on mobile device. Use this to open a specific app. You can find the package name of the app by calling list_apps_on_device.", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + packageName: z.string().describe("The package name of the app to launch"), + locale: z.string().optional().describe("Comma-separated BCP 47 locale tags to launch the app with (e.g., fr-FR,en-GB)"), + }, + { destructiveHint: true }, + async ({ device, packageName, locale }) => { + const robot = getRobotFromDevice(device); + await robot.launchApp(packageName, locale); + return `Launched app ${packageName}`; + } + ); + + tool( + "mobile_terminate_app", + "Terminate App", + "Stop and terminate an app on mobile device", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + packageName: z.string().describe("The package name of the app to terminate"), + }, + { destructiveHint: true }, + async ({ device, packageName }) => { + const robot = getRobotFromDevice(device); + await robot.terminateApp(packageName); + return `Terminated app ${packageName}`; + } + ); + + tool( + "mobile_install_app", + "Install App", + "Install an app on mobile device", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + path: z.string().describe("The path to the app file to install. For iOS simulators, provide a .zip file or a .app directory. For Android provide an .apk file. For iOS real devices provide an .ipa file"), + }, + { destructiveHint: true }, + async ({ device, path }) => { + const robot = getRobotFromDevice(device); + await robot.installApp(path); + return `Installed app from ${path}`; + } + ); + + tool( + "mobile_uninstall_app", + "Uninstall App", + "Uninstall an app from mobile device", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + bundle_id: z.string().describe("Bundle identifier (iOS) or package name (Android) of the app to be uninstalled"), + }, + { destructiveHint: true }, + async ({ device, bundle_id }) => { + const robot = getRobotFromDevice(device); + await robot.uninstallApp(bundle_id); + return `Uninstalled app ${bundle_id}`; + } + ); + + tool( + "mobile_get_screen_size", + "Get Screen Size", + "Get the screen size of the mobile device in pixels", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.") + }, + { readOnlyHint: true }, + async ({ device }) => { + const robot = getRobotFromDevice(device); + const screenSize = await robot.getScreenSize(); + return `Screen size is ${screenSize.width}x${screenSize.height} pixels`; + } + ); + + tool( + "mobile_click_on_screen_at_coordinates", + "Click Screen", + "Click on the screen at given x,y coordinates. If clicking on an element, use the list_elements_on_screen tool to find the coordinates.", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + x: z.coerce.number().describe("The x coordinate to click on the screen, in pixels"), + y: z.coerce.number().describe("The y coordinate to click on the screen, in pixels"), + }, + { destructiveHint: true }, + async ({ device, x, y }) => { + const robot = getRobotFromDevice(device); + await robot.tap(x, y); + return `Clicked on screen at coordinates: ${x}, ${y}`; + } + ); + + tool( + "mobile_double_tap_on_screen", + "Double Tap Screen", + "Double-tap on the screen at given x,y coordinates.", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + x: z.coerce.number().describe("The x coordinate to double-tap, in pixels"), + y: z.coerce.number().describe("The y coordinate to double-tap, in pixels"), + }, + { destructiveHint: true }, + async ({ device, x, y }) => { + const robot = getRobotFromDevice(device); + await robot!.doubleTap(x, y); + return `Double-tapped on screen at coordinates: ${x}, ${y}`; + } + ); + + tool( + "mobile_long_press_on_screen_at_coordinates", + "Long Press Screen", + "Long press on the screen at given x,y coordinates. If long pressing on an element, use the list_elements_on_screen tool to find the coordinates.", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + x: z.coerce.number().describe("The x coordinate to long press on the screen, in pixels"), + y: z.coerce.number().describe("The y coordinate to long press on the screen, in pixels"), + duration: z.coerce.number().min(1).max(10000).optional().describe("Duration of the long press in milliseconds. Defaults to 500ms."), + }, + { destructiveHint: true }, + async ({ device, x, y, duration }) => { + const robot = getRobotFromDevice(device); + const pressDuration = duration ?? 500; + await robot.longPress(x, y, pressDuration); + return `Long pressed on screen at coordinates: ${x}, ${y} for ${pressDuration}ms`; + } + ); + + tool( + "mobile_list_elements_on_screen", + "List Screen Elements", + "List elements on screen and their coordinates, with display text or accessibility label. Do not cache this result.", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.") + }, + { readOnlyHint: true }, + async ({ device }) => { + const robot = getRobotFromDevice(device); + const elements = await robot.getElementsOnScreen(); + + const result = elements.map(element => { + const out: any = { + type: element.type, + text: element.text, + label: element.label, + name: element.name, + value: element.value, + identifier: element.identifier, + coordinates: { + x: element.rect.x, + y: element.rect.y, + width: element.rect.width, + height: element.rect.height, + }, + }; + + if (element.focused) { + out.focused = true; + } + + return out; + }); + + return `Found these elements on screen: ${JSON.stringify(result)}`; + } + ); + + tool( + "mobile_press_button", + "Press Button", + "Press a button on device", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + button: z.string().describe("The button to press. Supported buttons: BACK (android only), HOME, VOLUME_UP, VOLUME_DOWN, ENTER, DPAD_CENTER (android tv only), DPAD_UP (android tv only), DPAD_DOWN (android tv only), DPAD_LEFT (android tv only), DPAD_RIGHT (android tv only)"), + }, + { destructiveHint: true }, + async ({ device, button }) => { + const robot = getRobotFromDevice(device); + await robot.pressButton(button); + return `Pressed the button: ${button}`; + } + ); + + tool( + "mobile_open_url", + "Open URL", + "Open a URL in browser on device", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + url: z.string().describe("The URL to open"), + }, + { destructiveHint: true }, + async ({ device, url }) => { + const allowUnsafeUrls = process.env.MOBILEMCP_ALLOW_UNSAFE_URLS === "1"; + if (!allowUnsafeUrls && !url.startsWith("http://") && !url.startsWith("https://")) { + throw new ActionableError("Only http:// and https:// URLs are allowed. Set MOBILEMCP_ALLOW_UNSAFE_URLS=1 to allow other URL schemes."); + } + + const robot = getRobotFromDevice(device); + await robot.openUrl(url); + return `Opened URL: ${url}`; + } + ); + + tool( + "mobile_swipe_on_screen", + "Swipe Screen", + "Swipe on the screen", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + direction: z.enum(["up", "down", "left", "right"]).describe("The direction to swipe"), + x: z.coerce.number().optional().describe("The x coordinate to start the swipe from, in pixels. If not provided, uses center of screen"), + y: z.coerce.number().optional().describe("The y coordinate to start the swipe from, in pixels. If not provided, uses center of screen"), + distance: z.coerce.number().optional().describe("The distance to swipe in pixels. Defaults to 400 pixels for iOS or 30% of screen dimension for Android"), + }, + { destructiveHint: true }, + async ({ device, direction, x, y, distance }) => { + const robot = getRobotFromDevice(device); + + if (x !== undefined && y !== undefined) { + // Use coordinate-based swipe + await robot.swipeFromCoordinate(x, y, direction, distance); + const distanceText = distance ? ` ${distance} pixels` : ""; + return `Swiped ${direction}${distanceText} from coordinates: ${x}, ${y}`; + } else { + // Use center-based swipe + await robot.swipe(direction); + return `Swiped ${direction} on screen`; + } + } + ); + + tool( + "mobile_type_keys", + "Type Text", + "Type text into the focused element", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + text: z.string().describe("The text to type"), + submit: z.boolean().describe("Whether to submit the text. If true, the text will be submitted as if the user pressed the enter key."), + }, + { destructiveHint: true }, + async ({ device, text, submit }) => { + const robot = getRobotFromDevice(device); + await robot.sendKeys(text); + + if (submit) { + await robot.pressButton("ENTER"); + } + + return `Typed text: ${text}`; + } + ); + + tool( + "mobile_save_screenshot", + "Save Screenshot", + "Save a screenshot of the mobile device to a file", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + saveTo: z.string().describe("The path to save the screenshot to. Filename must end with .png, .jpg, or .jpeg"), + }, + { destructiveHint: true }, + async ({ device, saveTo }) => { + validateFileExtension(saveTo, ALLOWED_SCREENSHOT_EXTENSIONS, "save_screenshot"); + validateOutputPath(saveTo); + + const robot = getRobotFromDevice(device); + + const screenshot = await robot.getScreenshot(); + fs.writeFileSync(saveTo, screenshot); + return `Screenshot saved to: ${saveTo}`; + } + ); + + server.registerTool( + "mobile_take_screenshot", + { + title: "Take Screenshot", + description: "Take a screenshot of the mobile device. Use this to understand what's on screen, if you need to press an element that is available through view hierarchy then you must list elements on screen instead. Do not cache this result.", + inputSchema: { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.") + }, + annotations: { + readOnlyHint: true, + }, + }, + async ({ device }) => { + try { + const robot = getRobotFromDevice(device); + const screenSize = await robot.getScreenSize(); + + let screenshot = await robot.getScreenshot(); + let mimeType = "image/png"; + + // validate we received a png, will throw exception otherwise + const image = new PNG(screenshot); + const pngSize = image.getDimensions(); + if (pngSize.width <= 0 || pngSize.height <= 0) { + throw new ActionableError("Screenshot is invalid. Please try again."); + } + + if (isScalingAvailable()) { + trace("Image scaling is available, resizing screenshot"); + const image = Image.fromBuffer(screenshot); + const beforeSize = screenshot.length; + screenshot = image.resize(Math.floor(pngSize.width / screenSize.scale)) + .jpeg({ quality: 75 }) + .toBuffer(); + + const afterSize = screenshot.length; + trace(`Screenshot resized from ${beforeSize} bytes to ${afterSize} bytes`); + + mimeType = "image/jpeg"; + } + + const screenshot64 = screenshot.toString("base64"); + trace(`Screenshot taken: ${screenshot.length} bytes`); + posthog("tool_invoked", { + "ToolName": "mobile_take_screenshot", + "ScreenshotFilesize": screenshot64.length, + "ScreenshotMimeType": mimeType, + "ScreenshotWidth": pngSize.width, + "ScreenshotHeight": pngSize.height, + }).then(); + + return { + content: [{ type: "image", data: screenshot64, mimeType }] + }; + } catch (err: any) { + error(`Error taking screenshot: ${err.message} ${err.stack}`); + return { + content: [{ type: "text", text: `Error: ${err.message}` }], + isError: true, + }; + } + } + ); + + tool( + "mobile_set_orientation", + "Set Orientation", + "Change the screen orientation of the device", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + orientation: z.enum(["portrait", "landscape"]).describe("The desired orientation"), + }, + { destructiveHint: true }, + async ({ device, orientation }) => { + const robot = getRobotFromDevice(device); + await robot.setOrientation(orientation); + return `Changed device orientation to ${orientation}`; + } + ); + + tool( + "mobile_get_orientation", + "Get Orientation", + "Get the current screen orientation of the device", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.") + }, + { readOnlyHint: true }, + async ({ device }) => { + const robot = getRobotFromDevice(device); + const orientation = await robot.getOrientation(); + return `Current device orientation is ${orientation}`; + } + ); + + tool( + "mobile_start_screen_recording", + "Start Screen Recording", + "Start recording the screen of a mobile device. The recording runs in the background until stopped with mobile_stop_screen_recording. Returns the path where the recording will be saved.", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + output: z.string().optional().describe("The file path to save the recording to. Filename must end with .mp4. If not provided, a temporary path will be used."), + timeLimit: z.coerce.number().optional().describe("Maximum recording duration in seconds. The recording will stop automatically after this time."), + }, + { destructiveHint: true }, + async ({ device, output, timeLimit }) => { + if (output) { + validateFileExtension(output, ALLOWED_RECORDING_EXTENSIONS, "start_screen_recording"); + validateOutputPath(output); + } + + getRobotFromDevice(device); + + if (activeRecordings.has(device)) { + throw new ActionableError(`Device "${device}" is already being recorded. Stop the current recording first with mobile_stop_screen_recording.`); + } + + const outputPath = output || path.join(os.tmpdir(), `screen-recording-${Date.now()}.mp4`); + + const args = ["screenrecord", "--device", device, "--output", outputPath, "--silent"]; + if (timeLimit !== undefined) { + args.push("--time-limit", String(timeLimit)); + } + + const child = mobilecli.spawnCommand(args); + + const cleanup = () => { + activeRecordings.delete(device); + }; + + child.on("error", cleanup); + child.on("exit", cleanup); + + activeRecordings.set(device, { + process: child, + outputPath, + startedAt: Date.now(), + }); + + return `Screen recording started. Output will be saved to: ${outputPath}`; + } + ); + + tool( + "mobile_stop_screen_recording", + "Stop Screen Recording", + "Stop an active screen recording on a mobile device. Returns the file path, size, and approximate duration of the recording.", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + }, + { destructiveHint: true }, + async ({ device }) => { + const recording = activeRecordings.get(device); + if (!recording) { + throw new ActionableError(`No active recording found for device "${device}". Start a recording first with mobile_start_screen_recording.`); + } + + const { process: child, outputPath, startedAt } = recording; + activeRecordings.delete(device); + + child.kill("SIGINT"); + + await new Promise(resolve => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, 5 * 60 * 1000); + + child.on("close", () => { + clearTimeout(timeout); + resolve(); + }); + }); + + const durationSeconds = Math.round((Date.now() - startedAt) / 1000); + + if (!fs.existsSync(outputPath)) { + return `Recording stopped after ~${durationSeconds}s but the output file was not found at: ${outputPath}`; + } + + const stats = fs.statSync(outputPath); + const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2); + + return `Recording stopped. File: ${outputPath} (${fileSizeMB} MB, ~${durationSeconds}s)`; + } + ); + + tool( + "mobile_list_crashes", + "List Crash Reports", + "List crash reports available on the device", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + }, + { readOnlyHint: true }, + async ({ device }) => { + ensureMobilecliAvailable(); + const response = mobilecli.crashesList(device); + return JSON.stringify(response.data); + } + ); + + tool( + "mobile_get_crash", + "Get Crash Report", + "Get the full content of a crash report by its ID. Use mobile_list_crashes to find available crash IDs.", + { + device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), + id: z.string().describe("The crash report ID to retrieve"), + }, + { readOnlyHint: true }, + async ({ device, id }) => { + ensureMobilecliAvailable(); + const response = mobilecli.crashesGet(device, id); + return response.data.content; + } + ); + + return server; +}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000000..9ba30337d4a --- /dev/null +++ b/src/utils.ts @@ -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.` + ); + } +} diff --git a/src/webdriver-agent.ts b/src/webdriver-agent.ts new file mode 100644 index 00000000000..a46a0c3fe3e --- /dev/null +++ b/src/webdriver-agent.ts @@ -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; +} + +export interface SourceTree { + value: SourceTreeElement; +} + +export class WebDriverAgent { + + constructor(private readonly host: string, private readonly port: number) { + } + + public async isRunning(): Promise { + 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 { + 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) { + 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 { + 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 { + 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 { + 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 { + const source = await this.getPageSource(); + return this.filterSourceElements(source.value); + } + + public async openUrl(url: string): Promise { + await this.withinSession(async sessionUrl => { + await fetch(`${sessionUrl}/url`, { + method: "POST", + body: JSON.stringify({ url }), + }); + }); + } + + public async getScreenshot(): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + }); + } +} diff --git a/test/android.ts b/test/android.ts new file mode 100644 index 00000000000..a0e0516d50f --- /dev/null +++ b/test/android.ts @@ -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); + }); +}); diff --git a/test/ios.ts b/test/ios.ts new file mode 100644 index 00000000000..afb50cd7991 --- /dev/null +++ b/test/ios.ts @@ -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); + }); +}); diff --git a/test/iphone-simulator.ts b/test/iphone-simulator.ts new file mode 100644 index 00000000000..37644ad7837 --- /dev/null +++ b/test/iphone-simulator.ts @@ -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"); + }); +}); diff --git a/test/mobilecli.test.ts b/test/mobilecli.test.ts new file mode 100644 index 00000000000..453c22f67c2 --- /dev/null +++ b/test/mobilecli.test.ts @@ -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"]); + }); + }); +}); diff --git a/test/png.ts b/test/png.ts new file mode 100644 index 00000000000..46434381cb5 --- /dev/null +++ b/test/png.ts @@ -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(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000000..04116e19946 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "skipLibCheck": true, + "esModuleInterop": true, + "moduleResolution": "node", + "strict": true, + "module": "CommonJS", + "outDir": "./lib" + }, + "include": [ + "src", + ], +} \ No newline at end of file From 599b1fa4dc161ffb89fd58466f270ed29ba8f1c0 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 3 Jul 2026 14:31:40 +0800 Subject: [PATCH 2/7] 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 --- .github/workflows/cd-mobile-mcp.yml | 62 + eslint.config.js | 1 + packages/mobile-mcp/.c8rc.json | 6 - packages/mobile-mcp/.editorconfig | 9 - .../.github/ISSUE_TEMPLATE/bug_report.md | 30 - .../mobile-mcp/.github/workflows/build.yml | 69 - packages/mobile-mcp/.husky/pre-commit | 1 - packages/mobile-mcp/.mocharc.yml | 1 - packages/mobile-mcp/.npmignore | 5 - packages/mobile-mcp/.vendored-from | 1 + packages/mobile-mcp/.vendored-patches.md | 22 + packages/mobile-mcp/CODE_OF_CONDUCT.md | 128 - packages/mobile-mcp/CONTRIBUTING.md | 48 - packages/mobile-mcp/README.md | 516 +--- packages/mobile-mcp/SECURITY.md | 26 - packages/mobile-mcp/mobile-mcp.png | Bin 97981 -> 0 bytes packages/mobile-mcp/package.json | 14 +- .../mobile-mcp/scripts/sync-from-upstream.sh | 48 + packages/mobile-mcp/src/android.ts | 1418 ++++++----- packages/mobile-mcp/src/coord-norm.ts | 180 ++ packages/mobile-mcp/src/ios.ts | 584 ++--- packages/mobile-mcp/src/iphone-simulator.ts | 569 ++--- packages/mobile-mcp/src/mobile-device.ts | 448 ++-- packages/mobile-mcp/src/robot.ts | 271 ++- packages/mobile-mcp/src/server.ts | 2136 ++++++++++------- packages/mobile-mcp/test/coord-norm.test.ts | 347 +++ 26 files changed, 3887 insertions(+), 3053 deletions(-) create mode 100644 .github/workflows/cd-mobile-mcp.yml delete mode 100644 packages/mobile-mcp/.c8rc.json delete mode 100644 packages/mobile-mcp/.editorconfig delete mode 100644 packages/mobile-mcp/.github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 packages/mobile-mcp/.github/workflows/build.yml delete mode 100755 packages/mobile-mcp/.husky/pre-commit delete mode 100644 packages/mobile-mcp/.mocharc.yml delete mode 100644 packages/mobile-mcp/.npmignore create mode 100644 packages/mobile-mcp/.vendored-from create mode 100644 packages/mobile-mcp/.vendored-patches.md delete mode 100644 packages/mobile-mcp/CODE_OF_CONDUCT.md delete mode 100644 packages/mobile-mcp/CONTRIBUTING.md delete mode 100644 packages/mobile-mcp/SECURITY.md delete mode 100644 packages/mobile-mcp/mobile-mcp.png create mode 100755 packages/mobile-mcp/scripts/sync-from-upstream.sh create mode 100644 packages/mobile-mcp/src/coord-norm.ts create mode 100644 packages/mobile-mcp/test/coord-norm.test.ts diff --git a/.github/workflows/cd-mobile-mcp.yml b/.github/workflows/cd-mobile-mcp.yml new file mode 100644 index 00000000000..ad1a4062391 --- /dev/null +++ b/.github/workflows/cd-mobile-mcp.yml @@ -0,0 +1,62 @@ +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)' + 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: Publish + if: ${{ !inputs.dry_run }} + working-directory: packages/mobile-mcp + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/eslint.config.js b/eslint.config.js index 62db9674451..f74416e2323 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, diff --git a/packages/mobile-mcp/.c8rc.json b/packages/mobile-mcp/.c8rc.json deleted file mode 100644 index fcf53a557b9..00000000000 --- a/packages/mobile-mcp/.c8rc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "include": ["lib/**/*.js"], - "exclude": ["node_modules/**", "lib/**/*.js.map"], - "reporter": ["html", "text"], - "temp-directory": "./coverage/tmp" -} diff --git a/packages/mobile-mcp/.editorconfig b/packages/mobile-mcp/.editorconfig deleted file mode 100644 index 862dd178fec..00000000000 --- a/packages/mobile-mcp/.editorconfig +++ /dev/null @@ -1,9 +0,0 @@ -[*] -indent_style = tab -indent_size = 8 -tab_width = 8 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true -max_line_length = 150 diff --git a/packages/mobile-mcp/.github/ISSUE_TEMPLATE/bug_report.md b/packages/mobile-mcp/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index a2c93cdbd95..00000000000 --- a/packages/mobile-mcp/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**Configuration (please complete the following information):** -- Agent: [e.g, Claude Desktop, Cursor] -- OS: [e.g, Mac, Linux, Windows] -- Device used: [e.g. Android, iOS, iOS Simulator] -- Device version: [e.g, 18.3.2] -- Device model: [e.g., Samsung Galaxy S25] - -**To Reproduce** -Steps to reproduce the behavior: -1. Use prompt '...' -2. Then do '...' -3. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. diff --git a/packages/mobile-mcp/.github/workflows/build.yml b/packages/mobile-mcp/.github/workflows/build.yml deleted file mode 100644 index f19b6695d10..00000000000 --- a/packages/mobile-mcp/.github/workflows/build.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Build -permissions: - id-token: write - contents: read - -on: - push: - branches: - - main - tags: - - "*.*.*" - pull_request: - branches: - - main - -jobs: - build: - permissions: - id-token: write - contents: read - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v5 - - - name: Use Node.js 24 - uses: actions/setup-node@v5 - with: - node-version: '24' - registry-url: 'https://registry.npmjs.org' - - - name: Install dependencies - run: npm ci --ignore-scripts - - - name: Audit - run: npm audit --audit-level high --omit dev - - - name: Lint - run: npm run lint - - - name: Update version - if: github.ref_type == 'tag' - env: - VERSION: ${{ github.ref_name }} - run: | - npm version "$VERSION" --no-git-tag-version - - - name: Build - run: | - npm run build - - - name: Publish - if: github.ref_type == 'tag' - run: | - npm publish --access public --provenance - - - name: Publish on github mcp registry - if: github.ref_type == 'tag' - env: - VERSION: ${{ github.ref_name }} - run: | - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" - brew install mcp-publisher - mcp-publisher login github-oidc - # patch version - sed -i "s/{{VERSION}}/$VERSION/g" server.json - mcp-publisher publish - mcp-publisher logout diff --git a/packages/mobile-mcp/.husky/pre-commit b/packages/mobile-mcp/.husky/pre-commit deleted file mode 100755 index 3867a0feb36..00000000000 --- a/packages/mobile-mcp/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -npm run lint diff --git a/packages/mobile-mcp/.mocharc.yml b/packages/mobile-mcp/.mocharc.yml deleted file mode 100644 index 25887e2d023..00000000000 --- a/packages/mobile-mcp/.mocharc.yml +++ /dev/null @@ -1 +0,0 @@ -timeout: 60s diff --git a/packages/mobile-mcp/.npmignore b/packages/mobile-mcp/.npmignore deleted file mode 100644 index 6ed45e43653..00000000000 --- a/packages/mobile-mcp/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -**/* -README.md -LICENSE -!index.* -!lib/**/*.js diff --git a/packages/mobile-mcp/.vendored-from b/packages/mobile-mcp/.vendored-from new file mode 100644 index 00000000000..6c656e971d5 --- /dev/null +++ b/packages/mobile-mcp/.vendored-from @@ -0,0 +1 @@ +c5d7d27fd diff --git a/packages/mobile-mcp/.vendored-patches.md b/packages/mobile-mcp/.vendored-patches.md new file mode 100644 index 00000000000..1068317c854 --- /dev/null +++ b/packages/mobile-mcp/.vendored-patches.md @@ -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. diff --git a/packages/mobile-mcp/CODE_OF_CONDUCT.md b/packages/mobile-mcp/CODE_OF_CONDUCT.md deleted file mode 100644 index f5e149ec84e..00000000000 --- a/packages/mobile-mcp/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,128 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -support@mobilenexthq.com. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/packages/mobile-mcp/CONTRIBUTING.md b/packages/mobile-mcp/CONTRIBUTING.md deleted file mode 100644 index 39e76a47dd8..00000000000 --- a/packages/mobile-mcp/CONTRIBUTING.md +++ /dev/null @@ -1,48 +0,0 @@ -# Contributing to Mobile MCP - -Thanks for your interest in contributing! This document covers the basics for getting set up and submitting changes. - -## Code of Conduct - -This project adheres to the [Contributor Covenant](CODE_OF_CONDUCT.md). By participating, you agree to keep the community welcoming and respectful. - -## Reporting bugs and requesting features - -- Search [existing issues](https://github.com/mobile-next/mobile-mcp/issues) before opening a new one. -- Open one issue per bug or feature request. -- For bugs, include: mobile-mcp version, OS, target platform (iOS/Android), device/simulator/emulator, a minimal reproduction, and the actual vs. expected behavior. -- For larger feature proposals, please open an issue to discuss the design before sending a PR. - -## Reporting security issues - -Please **do not** file public GitHub issues for security vulnerabilities — see [SECURITY.md](SECURITY.md) for the private reporting process. - -## Development setup - -Requirements: Node.js 22+, and Xcode command line tools / Android Platform Tools if you plan to exercise a simulator, emulator, or real device. - -```bash -git clone https://github.com/mobile-next/mobile-mcp.git -cd mobile-mcp -npm ci -npm run build - -# lint -npm run lint - -# tests (drives a real simulator/emulator/device via Playwright + c8 coverage) -npm test -``` - -`npm run watch` recompiles on change during development. A pre-commit hook (husky) runs `npm run lint` automatically. - -## Submitting changes - -- Branch off `main` and open a pull request when ready. -- Keep PRs focused — one concern per PR. -- Run `npm run build`, `npm run lint`, and `npm test` locally before pushing. -- CI (`.github/workflows/build.yml`) runs an npm audit, lint, and build on every PR — make sure it's green. - -## License - -By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE). diff --git a/packages/mobile-mcp/README.md b/packages/mobile-mcp/README.md index 6119d4153f6..520381ee0b0 100644 --- a/packages/mobile-mcp/README.md +++ b/packages/mobile-mcp/README.md @@ -1,505 +1,137 @@ -# Mobile Next - MCP server for Mobile Development and Automation | iOS, Android, Simulator, Emulator, and Real Devices +# @qwen-code/mobile-mcp -This is a [Model Context Protocol (MCP) server](https://github.com/modelcontextprotocol) that enables scalable mobile automation, development through a platform-agnostic interface, eliminating the need for distinct iOS or Android knowledge. You can run it on emulators, simulators, and real devices (iOS and Android). -This server allows Agents and LLMs to interact with native iOS/Android applications and devices through structured accessibility snapshots or coordinate-based taps based on screenshots. +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. -

- - Mobile Next Stars - - - npm - - - - - - Install in VS Code - - - wiki - - - join on Slack - -

+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 -https://github.com/user-attachments/assets/bb084777-beb3-4930-ae6f-8d3fe694ddde +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 -

- - mobile-mcp - -

+### 1. Opt-in 0-1000 relative coordinate mode -### 🚀 Mobile MCP Roadmap: Building the Future of Mobile +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. -Join us on our journey as we continuously enhance Mobile MCP! -Check out our detailed roadmap to see upcoming features, improvements, and milestones. Your feedback is invaluable in shaping the future of mobile automation. +**Environment variables:** -👉 [Explore the Roadmap](https://github.com/orgs/mobile-next/projects/3) +| 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:** -### Main use cases +- **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. -How we help to scale mobile automation: +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. -- 📲 Native app automation (iOS and Android) for testing or data-entry scenarios. -- 📝 Scripted flows and form interactions without manually controlling simulators/emulators or real devices (iPhone, Samsung, Google Pixel etc) -- 🧭 Automating multi-step user journeys driven by an LLM -- 👆 General-purpose mobile application interaction for agent-based frameworks -- 🤖 Enables agent-to-agent communication for mobile automation usecases, data extraction +### 2. Extended Android install options -## Main Features +`mobile_install_app` now supports Android-specific flags: -- 🚀 **Fast and lightweight**: Uses native accessibility trees for most interactions, or screenshot based coordinates where a11y labels are not available. -- 🤖 **LLM-friendly**: No computer vision model required in Accessibility (Snapshot). -- 🧿 **Visual Sense**: Evaluates and analyses what's actually rendered on screen to decide the next action. If accessibility data or view-hierarchy coordinates are unavailable, it falls back to screenshot-based analysis. -- 📊 **Deterministic tool application**: Reduces ambiguity found in purely screenshot-based approaches by relying on structured data whenever possible. -- 📺 **Extract structured data**: Enables you to extract structred data from anything visible on screen. +| 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 | -### 🎯 Platform Support +iOS/simulator silently ignores these options. -| Platform | Supported | -|----------|:---------:| -| iOS Real Device | ✅ | -| iOS Simulator | ✅ | -| Android Real Device | ✅ | -| Android Emulator | ✅ | +### 3. Android-specific tools -## 🔧 Available MCP 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) | -
-📱 Click to expand tool list - List of Mobile MCP tools for automation and development +### 4. Telemetry disabled by default -> For detailed implementation and parameter specifications, see [`src/server.ts`](src/server.ts) +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 in pixels -- **`mobile_get_orientation`** - Get the current screen orientation of the device -- **`mobile_set_orientation`** - Change the screen orientation (portrait/landscape) +- **`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 on the device -- **`mobile_launch_app`** - Launch an app using its package name -- **`mobile_terminate_app`** - Stop and terminate a running app -- **`mobile_install_app`** - Install an app from file (.apk, .ipa, .app, .zip) -- **`mobile_uninstall_app`** - Uninstall an app using bundle ID or package name - -### Screen Interaction -- **`mobile_take_screenshot`** - Take a screenshot to understand what's on screen -- **`mobile_save_screenshot`** - Save a screenshot to a file -- **`mobile_list_elements_on_screen`** - List UI elements with their coordinates and properties -- **`mobile_click_on_screen_at_coordinates`** - Click at specific x,y coordinates -- **`mobile_double_tap_on_screen`** - Double-tap at specific coordinates -- **`mobile_long_press_on_screen_at_coordinates`** - Long press at specific coordinates -- **`mobile_swipe_on_screen`** - Swipe in any direction (up, down, left, right) - -### Input & Navigation -- **`mobile_type_keys`** - Type text into focused elements with optional submit -- **`mobile_press_button`** - Press device buttons (HOME, BACK, VOLUME_UP/DOWN, ENTER, etc.) -- **`mobile_open_url`** - Open URLs in the device browser - -### Platform Support -- **iOS**: Simulators and real devices via native accessibility and WebDriverAgent -- **Android**: Emulators and real devices via ADB and UI Automator -- **Cross-platform**: Unified API works across both iOS and Android - -
- -## 🏗️ Mobile MCP Architecture - -

- - mobile-mcp - -

- - -## 📚 Wiki page - -More details in our [wiki page](https://github.com/mobile-next/mobile-mcp/wiki) for setup, configuration and debugging related questions. - - -## Installation and configuration - -**Standard config** works in most of the tools: - -```json -{ - "mcpServers": { - "mobile-mcp": { - "command": "npx", - "args": ["-y", "@mobilenext/mobile-mcp@latest"] - } - } -} -``` - -
-Amp - -Add via the Amp VS Code extension settings screen or by updating your `settings.json` file: - -```json -"amp.mcpServers": { - "mobile-mcp": { - "command": "npx", - "args": [ - "@mobilenext/mobile-mcp@latest" - ] - } -} -``` - -**Amp CLI:** - -Run the following command in your terminal: - -```bash -amp mcp add mobile-mcp -- npx @mobilenext/mobile-mcp@latest -``` - -
- -
-Cline - -To setup Cline, just add the json above to your MCP settings file. - -[More in our wiki](https://github.com/mobile-next/mobile-mcp/wiki/Cline) - -
- -
-Claude Code - -Use the Claude Code CLI to add the Mobile MCP server: - -```bash -claude mcp add mobile-mcp -- npx -y @mobilenext/mobile-mcp@latest -``` -
- -
-Claude Desktop - -Follow the [MCP install guide](https://modelcontextprotocol.io/quickstart/user), use json configuration above. - -
- -
-Codex - -Use the Codex CLI to add the Mobile MCP server: - -```bash -codex mcp add mobile-mcp npx "@mobilenext/mobile-mcp@latest" -``` - -Alternatively, create or edit the configuration file `~/.codex/config.toml` and add: - -```toml -[mcp_servers.mobile-mcp] -command = "npx" -args = ["@mobilenext/mobile-mcp@latest"] -``` - -For more information, see the Codex MCP documentation. - -
- -
-Copilot - -Use the Copilot CLI to interactively add the Mobile MCP server: - -```text -/mcp add -``` - -You can edit the configuration file `~/.copilot/mcp-config.json` and add: - -```json -{ - "mcpServers": { - "mobile-mcp": { - "type": "local", - "command": "npx", - "tools": [ - "*" - ], - "args": [ - "@mobilenext/mobile-mcp@latest" - ] - } - } -} -``` - -For more information, see the Copilot CLI documentation. - -
- -
-Cursor - -#### Click the button to install: - -[Install in Cursor](https://cursor.com/en/install-mcp?name=Mobile%20MCP&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBtb2JpbGVuZXh0L21vYmlsZS1tY3BAbGF0ZXN0Il19) - -#### Or install manually: - -Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx -y @mobilenext/mobile-mcp@latest`. You can also verify config or add command like arguments via clicking `Edit`. - -
-
-Gemini CLI +- **`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 -Use the Gemini CLI to add the Mobile MCP server: - -```bash -gemini mcp add mobile-mcp npx -y @mobilenext/mobile-mcp@latest -``` +### 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 -
-Goose +### Input & Navigation -#### Click the button to install: +- **`mobile_type_keys`** - Type text into focused element +- **`mobile_press_button`** - Press device buttons (HOME, BACK, etc.) +- **`mobile_open_url`** - Open URL in browser -[![Install in Goose](https://block.github.io/goose/img/extension-install-dark.svg)](https://block.github.io/goose/extension?cmd=npx&arg=-y&arg=%40mobilenext%2Fmobile-mcp%40latest&id=mobile-mcp&name=Mobile%20MCP&description=Mobile%20automation%20and%20development%20for%20iOS%2C%20Android%2C%20simulators%2C%20emulators%2C%20and%20real%20devices) +### Recording & Debugging -#### Or install manually: +- **`mobile_start_screen_recording`** / **`mobile_stop_screen_recording`** - Screen recording +- **`mobile_list_crashes`** / **`mobile_get_crash`** - Crash reports -Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx -y @mobilenext/mobile-mcp@latest`. Click "Add Extension". +### Android Only -
+- **`mobile_ui_dump`** - Full UI hierarchy XML dump +- **`mobile_adb_pull`** / **`mobile_adb_push`** - File transfer via ADB -
-Kiro +## Usage -Follow the MCP Servers [documentation](https://kiro.dev/docs/mcp/). For example in `.kiro/settings/mcp.json`: +### MCP server configuration ```json { "mcpServers": { "mobile-mcp": { "command": "npx", - "args": [ - "@mobilenext/mobile-mcp@latest" - ] + "args": ["@qwen-code/mobile-mcp"] } } } ``` -
- -
-opencode - -Follow the MCP Servers documentation. For example in `~/.config/opencode/opencode.json`: - -```json -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "mobile-mcp": { - "type": "local", - "command": [ - "npx", - "@mobilenext/mobile-mcp@latest" - ], - "enabled": true - } - } -} -``` - -
- -
-Qodo Gen - -Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the standard config above. - -Click Save. - -
- - -
-Windsurf - -Open Windsurf settings, navigate to MCP servers, and add a new server using the `command` type with: - -```bash -npx @mobilenext/mobile-mcp@latest -``` - -Or add the standard config under `mcpServers` in your settings as shown above. - -
- - -[Read more in our wiki](https://github.com/mobile-next/mobile-mcp/wiki)! 🚀 - -### SSE Server Mode - -By default, Mobile MCP runs over stdio. To start an SSE server instead, use the `--listen` flag: - -```bash -npx @mobilenext/mobile-mcp@latest --listen 3000 -``` - -This binds to `localhost:3000`. To bind to a specific interface: - -```bash -npx @mobilenext/mobile-mcp@latest --listen 0.0.0.0:3000 -``` - -Then configure your MCP client to connect to `http://:3000/mcp`. - -#### Authorization - -To require Bearer token authorization on the SSE server, set the `MOBILEMCP_AUTH` environment variable: - -```bash -MOBILEMCP_AUTH=my-secret-token npx @mobilenext/mobile-mcp@latest --listen 3000 -``` - -When set, all requests must include the header `Authorization: Bearer my-secret-token`. - -### 🛠️ How to Use 📝 - -After adding the MCP server to your IDE/Client, you can instruct your AI assistant to use the available tools. -For example, in Cursor's agent mode, you could use the prompts below to quickly validate, test and iterate on UI intereactions, read information from screen, go through complex workflows. -Be descriptive, straight to the point. - -### ✨ Example Prompts - -#### Workflows - -You can specifiy detailed workflows in a single prompt, verify business logic, setup automations. You can go crazy: - -**Search for a video, comment, like and share it.** -``` -Find the video called " Beginner Recipe for Tonkotsu Ramen" by Way of -Ramen, click on like video, after liking write a comment " this was -delicious, will make it next Friday", share the video with the first -contact in your whatsapp list. -``` - -**Download a successful step counter app, register, setup workout and 5-star the app** -``` -Find and Download a free "Pomodoro" app that has more than 1k stars. -Launch the app, register with my email, after registration find how to -start a pomodoro timer. When the pomodoro timer started, go back to the -app store and rate the app 5 stars, and leave a comment how useful the -app is. -``` - -**Search in Substack, read, highlight, comment and save an article** -``` -Open Substack website, search for "Latest trends in AI automation 2025", -open the first article, highlight the section titled "Emerging AI trends", -and save article to reading list for later review, comment a random -paragraph summary. -``` - -**Reserve a workout class, set timer** -``` -Open ClassPass, search for yoga classes tomorrow morning within 2 miles, -book the highest-rated class at 7 AM, confirm reservation, -setup a timer for the booked slot in the phone -``` - -**Find a local event, setup calendar event** -``` -Open Eventbrite, search for AI startup meetup events happening this -weekend in "Austin, TX", select the most popular one, register and RSVP -yes to the event, setup a calendar event as a reminder. -``` - -**Check weather forecast and send a Whatsapp/Telegram/Slack message** -``` -Open Weather app, check tomorrow's weather forecast for "Berlin", and -send the summary via Whatsapp/Telegram/Slack to contact "Lauren Trown", -thumbs up their response. -``` - -- **Schedule a meeting in Zoom and share invite via email** -``` -Open Zoom app, schedule a meeting titled "AI Hackathon" for tomorrow at -10AM with a duration of 1 hour, copy the invitation link, and send it via -Gmail to contacts "team@example.com". -``` -[More prompt examples can be found here.](https://github.com/mobile-next/mobile-mcp/wiki/Prompt-Example-repo-list) - -## Prerequisites - -What you will need to connect MCP with your agent and mobile devices: - -- [Xcode command line tools](https://developer.apple.com/xcode/resources/) -- [Android Platform Tools](https://developer.android.com/tools/releases/platform-tools) -- [node.js](https://nodejs.org/en/download/) v22+ -- [MCP](https://modelcontextprotocol.io/introduction) supported foundational models or agents, like [Claude MCP](https://modelcontextprotocol.io/quickstart/server), [OpenAI Agent SDK](https://openai.github.io/openai-agents-python/mcp/), [Copilot Studio](https://www.microsoft.com/en-us/microsoft-copilot/blog/copilot-studio/introducing-model-context-protocol-mcp-in-copilot-studio-simplified-integration-with-ai-apps-and-agents/) - -### Simulators, Emulators, and Real Devices - -When launched, Mobile MCP can connect to: -- iOS Simulators on macOS/Linux -- Android Emulators on Linux/Windows/macOS -- iOS or Android real devices (requires proper platform tools and drivers) - -Make sure you have your mobile platform SDKs (Xcode, Android SDK) installed and configured properly before running Mobile Next Mobile MCP. - -### Telemetry - -Mobile MCP collects anonymous usage telemetry via PostHog. To disable it, set the `MOBILEMCP_DISABLE_TELEMETRY` environment variable: - -```bash -MOBILEMCP_DISABLE_TELEMETRY=1 npx @mobilenext/mobile-mcp@latest -``` - -For json configurations: +With relative coordinates enabled: ```json { "mcpServers": { "mobile-mcp": { "command": "npx", - "args": ["-y", "@mobilenext/mobile-mcp@latest"], + "args": ["@qwen-code/mobile-mcp"], "env": { - "MOBILEMCP_DISABLE_TELEMETRY": "1" + "MOBILE_MCP_COORDINATE_SPACE": "1" } } } } ``` -### Running in "headless" mode on Simulators/Emulators - -When you do not have a real device connected to your machine, you can run Mobile MCP with an emulator or simulator in the background. - -For example, on Android: -1. Start an emulator (avdmanager / emulator command). -2. Run Mobile MCP with the desired flags - -On iOS, you'll need Xcode and to run the Simulator before using Mobile MCP with that simulator instance. -- `xcrun simctl list` -- `xcrun simctl boot "iPhone 16"` +### Prerequisites -# Thanks to all contributors ❤️ +- **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) -### We appreciate everyone who has helped improve this project. +## License - - - +Apache-2.0 (same as upstream) diff --git a/packages/mobile-mcp/SECURITY.md b/packages/mobile-mcp/SECURITY.md deleted file mode 100644 index 3dca51be2ee..00000000000 --- a/packages/mobile-mcp/SECURITY.md +++ /dev/null @@ -1,26 +0,0 @@ -# Security Policy - -## Supported Versions - -All versions of this project are currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| All | :white_check_mark: | - -## Important notes regarding use of mobile-mcp - -**mobile-mcp** is an MCP server for controlling a mobile device. Device may be a real iOS device, real Android device, an iOS Simulator or Android -Emulator. - -You may use mobile-mcp for various tasks, such as app development, marketing automation, quality assurance and so on. - -As a rule of thumb, verify which tools your Agent is invoking. It has access to the device or simulator, it may operate the device to the security -scope of that device. It is suggested that you use a device that is purposed for this use. - -## Reporting a Vulnerability - -To report a security vulnerability, please join our Slack channel at http://mobilenexthq.com/join-slack and DM the moderators with details of the vulnerability. - -We take all security reports seriously and will respond as quickly as possible. - diff --git a/packages/mobile-mcp/mobile-mcp.png b/packages/mobile-mcp/mobile-mcp.png deleted file mode 100644 index b9d29f9e401b06b2440f38f97281fbc522d94a58..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 97981 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

g71PUZ-Mj*POWkGPG~hL#2# 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

ODx$o0jP!B<8Y@k$oh^oxxo$}gm*CE+ zPoE~6Y0m6>W?zTL%p-U(&4*>X{h3$@URfx%ld9`8x3e-+&VI}=GIBQMF_r79jdJ@E z`wSatqlRV@)L4L-&^}?Ts<*E>-fKfv9}xMsdTN|ZzcLfoT3uM7>YMzqB&k;Za#7RS zv7voQnr2f^2OR?O&8Gqjzglro?_i!qX-a)`3h_Z@472MH>r-`+yPwL2k@lYMlRL_( z#yB~s=4_8@nmG4g4t_ZYXy^oHcV~IK(0lmX(|q$~U7FJ`H_8RiZulyXtU4Bkdsa_c zT5V0sr=C_GdlTCCOjn6wme28`D{E}4lvwG3n_?*8%jhXJ5zT59{n=C?mYBj5;dav6 z$>LqpEc7}NdJG5Fc_h>C%kh$O^_dgJ4rf0hsC&uuUqCZM%A%s#hME-6&9$}$?_6Oe zMYVj#scz{5n+8{76ct}>ym8P%Lg~|U4oX#7=C>RkFWKzAHHy6`@D)=DfH#AN*w#m% zqZwtCbSuiW_kx|g^dm@}JVQyi)ldu&Xo`Q_i)Ju3Q$#<~MHq^cE zK!n)K|L8L2*P->qy4jGcj7eh=Fs?kvm#)Lg!3??X%-~*+^Y3&}M!`Hyn8S#Gyr=ug zuwtImRoDIYF%MxQT}Fr|D6LywWB!n(TrV9R+8q|u1tsJ)%ChkXvs>$vty)_@TtP&P z9J+{;S+{MpKZw}vm2G_tq-epn_Hnq;7k>hl=)fM@O96= zRSEo12bc~kX|{|7gtm6k0K}(VfhS;enMT8_M^-n_YF7|KlzrNbu2H~oY0nEEv{ut5 ze<_o=;Oo33+Y~p&d96&fHLlks2Gg=JN2_V2Hr2G=qyhh0%#_c0wkYW{CS(NYy6)E{$7`CpQcS(8ro;~u7AT#pibH`kZif#qsm|)rDV_#Y{tnjB;+@@_6 zGOSK3nF~Y_V56&OdQJu|d%xTF#27EMnDu{OYWmLd`80V1_T{xb{90PI_l>dQ1Ic+@ zgwB@3y3Zm3njTW-`wY>CSIUwkKjBa?M;lgK%9nFR^=pf*@*4$^v`dD*?T;&k8hjIJ zttOw*qk(c*dgzi(8O=5EEW8+&_86@w7v-8iZP;JTB~DXY;%O;*x}UKAF`;eHHj+U&PYK2| z>~sC=q@z72*6=Jqnxq7bwt?{gA9Yze_%8GFoyi52tY?UiQCDT%EG;2WX8w=m563bP zDj=8O9$RBe&sx<;1FvdztNy?Zc6E_8a9G1Xz> zbrGxqB=_Pyp76YmDkrvJ?y-CO5{$uMx_1t~!dsuP4DwdZaO@;$fZxdjVl_r8hJIp6r8+}aVT1&`%NYkXTD z3M;p~;aSgfoBD2Fr|7+!7_0Y%j6(n;+m&K3gH3@lZ=iKZu0`i~g*w9^B~=~HHCx6Wt|F@Zp2Tbto1Tv)ivAOGR#Wepx=6XJO%0I@ zHE`8Vr64OOu067dPuKbO#3AoH19P>uZKWEP7(j4R))!eQ`u0*%o~@wW$BaZxe=>sa z!}2hP)4BTbi56c|wJz3mk0ZBLIY=K&647@<(f!P{CqHtvs3_4TcZcoXTH71dF`J$n zAB5~zS~b;fM{J$~2OL)}!6oZ&lM|R-BZAGh(yI(~jeryj?h+qkfi1n*JG}|amE6`- ztkacq_S%ug<_WaJwBEC}1YSrztu3o>i+vb&9hmhL25Eure6%;?&2pr9CJtz^;G&G0 z1X+(Tp`b4rOpj0(e!2z5AZm)VoQnz>`|930e5)eb;?Wj5ePv-VNHj4%rOw2&netss zv@uRtJ9l-h4RsXw-ESq7+jK%eyY-6cFF&cUEvI6_7hN z4YV;tgUEeuso^ z2254sjw{gGoE%gtYt$ID30Dd>CIabVuEysk!O8td4%NpRDBcUNF6QAyM_rfnhwEAg zhn$P%U~2+p4+YZb8TZC-zD6h#yw$?K?R262!x_HT;|vab2=AL{wp|7MFL4f+>d06M zY!koM(K*v`@mm=PNamYV)YpOh>?2^a*NN^cOVYG{@XvkKI#CgV3@as25o#Dz=wvg% zLFiZbwubcIK#^H29wt%Dm#{^N054m!f407p4+a#;;Na2l**Oge9;`(X@CsbR&Ck0# zoP?i-lSU7sr}k}IH|95658ilT-eFp72Q??-G-Mo(Dhtuxd8*ASXf)>ya)kxRBgVV$ z6#^;_n;~Lnd)R@x75U|lLmx31;{e}ctPJ_^n7 zUxtj*qlP6s%L-+#`nJc$+OeGA*Io2I<#E%zw{5>O;dDbQvInrcs&94uLzPr8l=%`drj8@&NP(9qfX~=m0P--wwT%u;AedFNv zF$Pm5UNG5kudO;JnY|zaG8vRMMz`+1EzQ4KHuTQJ928(mq@rE&Vt-iEmRcB+2Vn}1{dD7!r zbby5JH`IAL^L#yGdqH!XuN(Q2U1tgVauxSLmcy&~*k+L%R= zohw^zy)(?9u=;h+{%5+@NZVdyV#6=9Nwg?&Zec_Z4Wz~}ui-p_jxv>QvlE23^6@@< zrEPW>Ba!K~siKNwfP<;5?SpxaM0|)Eoo)%u)uPz(;@M2*HY0`bfk+RmlNW$RQYVKz zG;Y}diE>Nbs%W;AP23TQRg^0YHCW*fdm5IZ)i<)RGvl)z6woN3WxsTOdM;c4;BY*L zvrhLGx1ZQ#b$gU!f!fF9LYWsEZ8J3Ol65t)`14mHyV)J+dYouKp8p?LUmX@z+kHJC z2!e=+10o`VN+Z%8qJSa@$P66<(#_B`#!(x zn?K>2Irll|j=k4jYi-rFXS>W%yCRtRgNbmPfFn+T>b2c2SpVfaSO&898I!^6)sCgS zE5D#V1-HTBwRcnB7pHPY&=17LTYh>qQ5nA z-}G;F?xNd%7;k&xR-zIdO3#8p05XNK zPyFGNM!7#)4ie?F4t1hh?FME-kGNrqomng010zr;D{nN5G%j+>OD_UemnPrkYnkKe zlghlLP1i2+E7W`E@tG_Qn=EEO72iL`SuUMS=2BiL{n5DLNbN?8*rDREo|mWsX(|}I zhvSrTl26?>)QTWE^ql|`3Gc&5jT8@!ui?_-EZOLXq!ooVzS}>wM~s`4I%$W)EDSwC zu`ZI-Qlq)sgPI1W68%=hZ7{!B5F9C{@V5XScA1Ok4%^n_(pX=wh4thGmD^U{l!~7n z6KFN0q1Y@4`<8Ybs_5jH*!(=CpkiL@MuDnerk#jzeU{eGT~YPYY8YMA7IzbBJB*^Z zht}VH9)uWfeM$S#*rQG)ATTl&n|5W^@-n{d3aV%(^1;V`- z#wi@V@69eZnHc4fhNJ5SP0zB zqx5h_#^yTdT%s22tqqRp{dV1iwk~up?Wmmp(#^qKkLmewbAaUuo86*IP|Y=XBv?HM zQ-flgQY#g%i?#7%_%B8ukGH1? z5|2^vaffI(xf2c>HgsyHMjuphqE?J+$F&7}(g=cmpH>>;_QzZSTPH^q#oMOSRSyUS zboFMn8!CC^7yHFX1WoLCmYh}#$AuDk=Eo6dQM504{G9}Yt4&{$`o;_ z(^hFX#`uOcmWk}Mn+0~uWgH2V^Cc1c1GVIkA<*CTS9-`uM?cFpzAPQfwT>~=&hJ^6 zQ+xXRi<3^Gbi7&Jc?Lb3(Ll3@g61v18gXQaGP(K8GcClat1AnFdkLlsQ+X=tCl4QU zh{n3GZ@%pWPtDt)w3%M|EMK0aR5SP^rrt$N_qVaF%k8_(*qXW$nVejfx`82x*Rlbv ziQ4(9y@6v7M8KZ|6*oBjn;N0OkDYyi6qTgjkJ>u?GfKs^UL#q)ymoTb9y)NCiSLy)FXdmMl#gzTAX+>c$zB#Io6ZNIkeIpP9qU*RVcsgHJ2($D zI&|Orr8gwhi=8-~G8(vm<@4RHniUQ5Ft4OnJ?6!IfUa-*tF9<*okG*Y{tl`{ZCTxM z6M$s-@AezmWNSbG)EA?C%h4X$vSV$pedo2wd}SyFSAS%Q@_alU*s)QbYlab@2&~v9 zgeqL>St%V=-lhIwP9BHiMDZZI<)iK!&ncZpB%OH){Ex1J0Ym#oxH*1*h7Od1Q>W;m zwm?2{L-z7-`~EqZ`s{Fyaxw-62Tw?=rk@w(_h6}Fi_bZ@;>--aV@ zWxMs8oht5)xu0GKzd`(@EFcR4s>u5^_ZtSw5;qjY(?FM0uG^KJT14$N8490hTuJOS z6@itbyy11qZjTBceeRy#;vO}-cJX(`UotvbcByk6rKU8O7u2)S*Il#|9{Ul|55O8gd6Zhzg% z%L{kB(!c<#-C5-+ky{W7?Y^bl!1%c8nkH&URR1!2E zQNWX#P2Y8A3Kd-vB1`~1W@RUxA9s6HpUuSP=_)27o?7$LQb2YAa4d?Xp=QUnWATIn ziS(S|!`91^u=Tgvs^6A3jUUChvRTi=kRMzvJ>hut-xIqs;T1vWc4PhQp^QAAc$!QOJ zW4*qS4jjZqO};G=LyBrS@Id$KQnAF4kg5A~|BmK% zqc=Y`S+DdN*Ti1hVY!(7MAPQ0H*orzQr)1>crZOW#TK42p(KM?NNv)nHNU{^-aBu8 zeI&QS<~zIx1ls*Od3+4y!M4+R0L5{{Q>4gwg!JjRNKD%5za&uimDM2X$t`Ad^;jny zl!>xS_?8YIOPI^`*i^!F5)97`jhl_pyWsFOZY!W7AHW?hA6Xy{lmI{UKa8h!ef$CH4iJ6NhMjf5}p2&tx4J!mT9E4D9B{B zg-`uj-1&2>)XFRUm*tcSX|*Hn>s-ZJZX4GKajt`c8X-#Zno<3?Yl#UvU(9L4#6q?Q zKzR7H^wZVjr6+}=Lhmk#T!1}4f4V)o2smuW{?7THn_muVsL9iH3o~ zj`zV+rf&k!Z7)_rl-i=&3Sh{hUG(2eEY~@;|!qREDWoC{&arSiatyYd?6e7A@PI=p`=UaeS194vYhLEPUT?Naf2>-GYq34?3OYXX~O$ra-6Op zb};$bgsSO)2o+=%(Duo$k$>SOh{s;o#8WBpwPA^fTcTffhMEJX-&|wallv` z2GuQrhS1fRN(T3zsu*ON2bdMSKVhveBp>lk-1#SDkELl>&LiqFH#REGg$` z-|2$Hy;kYp)PUVFejW$fbqazX@ZOy~RCfcaJEFjXc$j zCeb9-0c4N^jqcmjx7|vdytXls%vXnmYY*3kHn$SFoE3JOa}3iBLJPlp!g$|P8x_U& z@Cu(FyKs%TvhFT4)cC}=9$q*4(XS9`YKVS~$A3F%&tC<6=wJs2MED}^CN<^aEiTJW zvp|k=(I^v^O%h(3s@XE!VBieg=zw%jE~8T9EcZ`IS>49rs+Ui=_K25Zc(3EH&b9$O zJMi+)&8q`8rF;b+9D9^pBJaf7Ob`dZ(R3F zwlx0k`C(^MzLI9`&EJqnHJLeAwnbLS0I3D?<*d(&C?xKZkcoLqfCZISsFljw3QkG* zFf4)=RRfJ(O%0dv;}X)8tFSexEx%?QelFZ3vAx>BAj=@G6ERY^eVSb?2Ze(Defjr? zDGY*YqC=fN*Q*7m8a)xepR1!s1YtE4k3OyLf(+$`v_0OzZUVuk79*swPN*nm?9#r#vvO+;NK0^K2+zryhqQ{KT<$k*jG<^0{aSLHm!N!|I zC+z;gY~I zn1v8y8%}U4oK#(w>TFowR6$3Z`elte{d#>S4&MUtOY~cs6;5ujqAK720%cOS;)6DJ5&>aT@MOnbRRDl}$P(QB-7kkTQ?H z2xaJ74@0;B<}o>?m6#i3!W00da~o~JS7>So2*R%R;&=_q;};GE9BS0n`hjhazbD9i z0_5VFKjUP4K#f$A(&<-Sp*UEnPqwntFSUd~wEI70TxVs;?9{R`;EpVa5+`qzyKPR@ zRn=b=wGN;}4t098rTiT?41bXB(eg|4?NeDSBB_+V-WVK6P+Na5{S=R1AsSd+XtZz# z%|axzaIo{%qvID6n(k$l!>HR+JUfVda2uChFc|@6-D<6^nRb;`T)gr%PDhJP-`D#W zTdOXpM%wiFXmWJPm|r&dBiInG(XIM!+y0Da22y9ecY?>RR)eY14i3-^88YP*o$B3IN^i$ro;#|W#Vpg@jydj+l-3e#|L zklI^0Qr+fJC)z*0IhK;V{L?nONm6Rv;+teWcdC_Y|LBiOA$^}UXAWI&MmZ9>v^niltgdfG-*oq6Oy54bV$eRoHTKrC_Qp*wxuCt#th@#dk5QK@O*-zjZ||nq*#FF z`J&1mlpAH-UabV}se(K-5EgRK?vIa;sruqq99@PZmdkg!`LpSID<4y!SJmZZN;o&S zHD|eme|~AJC0-24eIyr`tE%v}l9Fbefu$#*|5}ndWWpxnr}BfMH}=Vm9o{Ai4PTPm z@s-k>AcbAcAhr0?zf^0zoqG5Ll1&Xl(MMEJ_aINLH*HN18nJ-4YdW_RM9;x1&}Yk| z8^{qxHP6QXergD!0OLhEn2oqk86Rc=ZF<77zW`==MxKN`h50)7*n9)bhiZPXxR9I7}YX8LxZ(9&hO6 zcx9)N7kk@#uPwt3Vs(_M4Y6RxKk)LHSllmL9#$Lfb;7A@!+DsmH#E|2@E%HnA`L8v z^ohoHy}Mvj`~9-22!EKmtSIB@s(GsYzST@$wC!l$I^Pz|zi1J}av@D=YoE@$=i`05d_&KZ1O%9q^pF(HgekF=R$vK_RBt>wM#x9fkM zO4NL@*cMk`py1#3u6I8C4!iG|5W88MWm>jEzp9rNwk#woMP~0}FlY71@m5<*LH26r zcxG`}nGcCY^$p=@-ciPd&FmxLgzxccCtRlG)|s)!N_d*jQLuvIno`kis?sUk^r!Ix zlNny2>^8)~&JgPgplI)H9gENUIg8V+gnOa?lr-;KCxSo~RGAfvZ$t|qd=Dam_w$1U zz@9Z&{-Njld>PT75P!3pU)qn34CZg3cYf(8oh>pb)x;ert=<~QJ2LNVmAjgDg&rdx z`O8bv+H*MQ&0ES$zUJN3)#w@%8(8aUvL?Y&G0a7R?%QaBp-<{(xwZqPK!G)Oj!RIF zuGicxoZhpcYXipSMh)54ChtZH>a^>^Iw8um`>A|nd{5r3ypMG}#==}JQ;xxSIP%N?n&`(XHIniuk z!@sl(Ge=9sNc=qkko&RuyZ4Ovx|iB+OVj3vDP={?%an_)`{|1xCkA9cHM@w>)SJQB zETj2Yei;XpT_Ff8mKAj|co!LF38&O8azqk`8==9n0%f+=;HXY37lYskCew@iYdZQK z7(`ORs;Ih|VE4BRi|k|V^m>`tF@i}5}BRAy{A>i;nxe?S;DZ>35Ng3Z9=<4ai$}l#E_wn?^!-u zECGJ5LL?>gw8o;Yb|a|!xNxd}s+drPW*0PZ-%EZlf8-3}q!MfB1}|(Na-~vp)X1qS zPxVCtZAwAip;f8}jre|0u05V&$Tmt`A!Jj|;dyx${&l}R@29UmnzJ{tz%}DjajxYT zm&76$IX_oiLxC^rm((PNU@?R%M?c|p^!l2eO)lMy**H84-Ae;e`!)M@<#9W8M!8)i zc7^kNr&B{hbH{@h>${sjXbDJBmb0@`hAXbi@Qejo$dINXp<>BF8#%lEV_hu)K!~L0 zyicT9?22v7YX-3cJ`PR=!SHIE*K4l9Y&r~_JR>Q_t`%@Z|GduV5KWx2<}X3M$X>DVoUpUo z3F45J&dq1EIK$DZuSF(NStP!{Kb2y|SqB@x?M6hHbP0O?*)Bc*ML7scOI(sSfN;+H zj4|m*j=lG@OJ*1gWGGG%rJ5RuBrbinu7py_>MjDqU$gD+e|~{w)kxj=C7WximGYE< zDU1l&UbYyuN=+IW+K8vK$e|aQORb-kn9^gM?0Ugn2-P@1hcDt@el6_M_zc$;%?%xT zHZ#pM^0KW1=^|>#{8UGS{8-3&+~Q-Zu#lWrml&Jp8LL!qERjrq$4&lec6@Ed5tb)t zn$VeQdXS5ubSwzj#;Kau!lT0ic?<3(KMUcV0ph#?0Jo^*MR5Sw(;gh@shgNi3C87x z-WeM&Rq~oTwN_*G^UC)uHbehG*GG#J`056w>sg`scKhF%TY0Q=z{ur-&CokR=zNX2 z+9YP*z2e=kuvp(Bvn^XHqeXES@X*?&+;YHhHMOu{Z62p%Ys@vZ;;3a{02z-NBT609LRueeF z%G7~MQhV5#izwg%Q&3K&T}uNLO|4iC-))BoCJ zW~5q->q%cA)*J{wHiB||fk*iB%ZM6xNu6%S3fNuv7SzJ80*0v?o_&RGEhdqC^B-N4 zs=d!8wqav}l;m!u*VviWT4M4VedPVp#js%QPrDBGyzC;5tF+_KWj?7oPqt#JOYQZz z(eWCE)yhK;Mt&nq7eMkiW1M3A{k-?X3CDb}(3CKVV&&2kucqoy;?>1#O&?u6N7`r7 zZgM~ArE$=&8T4-%n_#z|>cU>)gM4P?I4^R<$14t5?q<~hr=@liKAjx3y-4DJ5g_#+ zxm{`I?UwYoP5H@dzKd!`TB_g4%_GUGpJrK`d*N6YQNGj^n`h~M56aELv?lURLmt9Q zDC$DCIK*qy(kf^Dv)0xO#1qj13uAWQn;L<|RszrHA0KWL33%44m(xNfQ}I0YC~LS* zV6m^M-d*juWESwqq!cFSB0{lGx9HrcdzD9LVC+uZ+g%L9@}PVGSf6AuqF=Y(S`yt9 zny8*?va^pa`U#xXIuWE)UE!hOfHR%Za5AV;(tdkRX=K2#$r!_aRD|(yXK6CY&Knq|p z3FYA0(G6Vzv1)?+u2!=T9XlldMA0%HRPq|mnmA>=+ww`4e*WZxdjDt5Bdu)0YTRKCi1^I7L5|KhG%FBaRauH*g5MhBJAnSoZ+ zZm35vUZ~9Az7#l;r0F>Gxqk(CDuEh1Oh&?)%h#EGlz8}{Bu%WvGvGO%cpgrYx9wDPH+`Ci9oiAGH8<&3mEjU z8w!oMYWwPqiwR7lUU*^n7|}C~>?MatQJd6R$kl?Knc5m@t{n{-4-qlI)E;#@CnF4o zbC9w;EglH{3u%O@0fk_FwuDTV)I!@nIt}aT==qUQ%~1X_0bbXai6iwpj7XwVgv2|1z_`p zOueox*Bvj;HKZnig;lXZhN0G0_#=YzEviUw$z_Xqb>TNwZqjNjPw1i`P&6cuA6-+uRFvi*id|*redZwj51kU%WCv>G!IaYR zaQ%7y$0U@{Gns}Hwe5{f0{{7DklW<5`qP;-)N<+9r^_*v`tPmdf8QkAqSt#+YgHXk zayIBba<5$!oOIx75d~HdkZu-0@V^Fi-??s7cU;ETi!c%)Q*kqwvf81#2*DBE<$u{Uq1zd!1v? zYH;+kirz~GSee_YtDmg|R4FIC?7IAE41SB6+E;eeFrS@lA!i+iSSFZXLMP?%+EpRz zy%TZmnt354h41J)_(~C}g}iD>xG|eY*lt>s?=C;R2|@CG02axHk#0v<71oUHyf-7- zrFlSeo(w?k5G%Hd{4A7BK~CzWT{Zb+ojymCcQZM~#bBw6wuZL67vjG089E_sX3s(} zb&T)}?$%8NN(|WZQcgQ-RenPAq?1k_>R(JtEMTfSne-H7i+CMsN?W^-$U-cD*?IPR ztr)G7<(D0UAfa*7KLPWkq3WaSAzo!2`dR+AC@M^7v@ju|?=^>|oziUIQw~&b2nVO} z_u=IaWo>*^2e$$HzT^31>$WLz>#EMT!f*-_*=L9BX+CwwUUgbeFf>?78No8%Y&y`5 zSe<9W|MBMh$x)~Dd1?8=fG)O!2NqB4PlKPX?KR5AHzfBCXnlK|?0DyK+-Zx4VBbjL zQ|V~XS=WILygOnux#scv=i402&MjdX1_11FaO~v6qwU$SigV=Lc_D_aF+R!f9Q1gy zuY1_Lc=;VYCneYY5!{V9gTOT7@CwrF+=M8qp>Rar1EcO3^UA%El|0W(L3M})D|i$! z|K&UG1kRQe>V`AZY>i$c=q}eB48P;lA1z?wq!>+=9mNOECyMgR3|y?q><2G~@%Uo? zw$co}+C-&$A>USFp@HcXwQ;;Y?FAQd%ShVu(;2N8m7&gJ?3CieS%|6+)lj_m28By#wp?O(Vt`z@y=q$Y4)RLxt2JE*-GlmJkB4(A{|xC1mbbq5dARw+4MOb)bBU^> zWG$wpZS`ttd-X18lzwYv`imsi3X39r3Veage--(AOrhaO`)(3C(>_?WdsH=3q+(tq zyaVM}4`0;NW#|cin1dsNfbz)V6XHY$Q4Ps(nWtRW^RK+u9^-yd%ed`Op8vD)bp+*M3m z=qT4{=1EhW`uWB)@KAQoMq9Shxk$dv6ddxOw>>aQu{_yvEm6{3f3&FN_2MvskX@?Y zu>N`X(NC;@g}Ku$N3GF*hZ)SoFh$`!x_JqDK!y;|bJ&g)5eC}BS`ehTf_=acD1)Iu zd{d?O7-a;YYbhU@A6`aon-?CHi7PjFWq$5^b&`O<&RXhQ7rWo#@pKemA(D6pT8{i= z-*On%Vg9Z41o_K=<8FGDclW%+uIeAKt8vbcvR@v3Zu$#%8HIH^ilggdkB15Q} zs;TiCI3~)e@-MwGesDhu_dw`+OSRqg4Xx^vl(1Mxqdu4x#4UHEwiJ>7&}r;Xbcs(xp_Chgm-E7Fq*Zv zN7IFB{~T?ffSHyznc$9`bf-b$9yIg2b)8-W>+e{_iy3i7%xoFrsRkJ#>r(XW?h?!oqa=1d!=;?AHiZ}Qc@x|OJLdit?0=JQC z^Y_E$r(5EcYX@!`EsVv?2upxHAj*LSBE$3Ne)7k#u?Uf!A8O<^_wmjY9M6?bE`J$9 z6+U(J{;DRYE<(48&b#v}nss4IK;z|bsSP32D!FEkLuJ}wU$qBri8~;#usqx zD80;?@^Q|rd0Vs9EL*G`9&WxR#%3)sKc3?Y>~ghVJvI3)gTwMbEV3%ID)U~zz6>EvpmnKw7$ z%Dq#pR4)u%vjkk{<<>l?@Lzk_E2igQ)TRI z_#Bm+m*?fJ4euJSX6|jd!C8?)PYP+Ca)J$o7cPPPec$=Cjz7?uCiK`d5AOX(<=hJ; zvWr~bg7y$W{AX@Hl`)m;VZ zqrZok5+AMVX?v97!msuIgnMhS;chpi4|~{h%flxLlWG7nv#XT>qI16~R;{1UdDJOL zte5O931tS*B4*mg0jh>=7p=ITtIe0t=9n0K*uPm!B3=@ZA(pK`c#5+0vMyPQX?HcX29Z2|S*OQ_tPtZ=fHN2YeG z*?Em_1*A<=WJL1*dEqpDUSbR5*@^0MKwZF-I^hQu{t_%8#hjKY z%9gl{DT?F!pm~`dadN`^neeDbYrO98H}-%yK$e<1tKP>fRBf_kk($oLCq2iXDR>Da zh|smxKXW-X)r@kL=cy=td~(O!D-?M!o%_j!(yBFJ8}N=fH{wId++Fh5FL-BmffmeB zv|`koozR*upQlGgj3r!PSPD(;`>Mkmd{ksychl*}^Qe~5^9SC5hMQH~bzVP1Pq2V` z=ykRstD}L@oLX@E;+&t0FooI0ZiMSrFmjuP-yrzh-{W4IOOJviTkvW+E$nO@UbE%} z57ooxQg)vE4}-jw`D`Gu2wg=AKfjXv=0=AcAReBFHq*kre57 z4W+*9lmqE7-mL_f=|W_D@G@3Y+wbB9)o>xe0Tlhz;BjB_H+Wh3q;$r-?f_yp+o&<0 zkyLMQiPC~ifi;m)F1&?$DMx*iy=l}mh}M`ujOJCEtQsA2vZ#SmMnPr0Y=RsF4Fr13 zxAa?z3}fdJx1ujuIiN zn+j|GfvJ1btlQci&Y6B=v?F!%u!OiGmIRV(YHTT5=jX%QJyZGNL62KKW=e?~$(c|f zT>pD^lX11T{`PGK#bc7=!MP9LdbTKDH(Uf}kYH9^C~{YG&1j4$EUfV;`hCN;7hN2~ zI2XSGl$f%7@Ekq$=+#(#$6D(a^6u+Qy9o)_%GxRMl`{<9dt1dFt#XFusVyGkqvI?W zoh*`WQ1kdWvr|Ypunh->9T;9uT5!fDs+u|Y;28zwANrkl%QVS+M#3H%!uq(pOosk= znZF!t_K}(xHv}a-p(Qp&8&{;i**N`$z%JBXAt8F+g(q_hUN zPXSC?IN~@^0&+y`tJepvpJ?fhe>;76^rJ}N&w5JU?x7O_)1Jkot{HJZ1`q5l0##q; z#A!G6c*zT@{lfz#bRpA2A*0F>f#T=6()g8g z?P#8?x4mbT*Yay?HZNO0aFxO`(*%=HO2x>Fkq^Mh&4YkjS;}_q)s9Qk9jzW05xLh| zFVQ4feiXB<48vjCjVklA6&<8of02mwUgEvoiw@9>COl5D;*P-e)%7aCyCo+47379U z%{3PJ%c#;kjBjTzTcMbi*it(jVxfXmSkeu@`+5z_5^v;*^8=-AcEHM4^e z$t?`$W9Z%hd&%+_V0JNcTpMCaw=BwV!~5F+_k!wc8_Sz4gp$*$DqM*VTATo=vU=6%B0enJ5n`23oxxte)0&I?W>y*5k(`$J$Cs`>&}k*nykK&sQsInmIQ z=;x<zZtJhWq8-2x>T+p#z-g_-dO<)bUTf`AuUzao`fw5#Fn*S=}&GrP*yL(vSo3 zd$()yc4)7sE<@31zLK_-x0G>z5!lt<z8 z`|I}TKyJvImjq`kr;sPNkF)jQknt3?*nVLgF3A=ZR^CYUAdKQ=k*L+L-`}GdJ}Hdl zcAIP-=-qXvDY%+s`_5rOvI=om&pWe!!28u`Vu!Rz&X;K$&Do>TKQh>fm#vOt6qqPr zAy$31*GNIepP{pamF73w0wWFF z1du{Uu#Zv~ky0aV;X0S~fQ)hmDVLWWbd8+C1-{!6Lr=v$L?n>nWBf<}o$VpcrY4By zq#xCu!fk54pz@%bV25Yb?Sdd)>xp2k*x`Cfe!OGKp9K-8bQVcP1w0MME6w zx!<`k32c}MGKP=tK4@jyC)nSx6%<s)*+GRS6162gz z+E5|8Hf>d^W+_rXvpyAP-_`3gcfP`S`?QqI2cApOc0;?74uYB+^{YqCR60ox!%I!0 za#5^TBF%XqkP+bHW%atg#GyJZ6kD^`nC#GrouSx5+P8Y z$PF`kx^OTPatD>X)G7$<4vlLcPB%Ru?XRE76s`uQmSM?DV$-$Ll5)g4knDnTVxU#W zo*uS@5PbA3t;#4-{_&9{u zO_vbG)JkT1>32E1Hy|dcA||wcV=%55YIt5X1tf2_;M`kKKVDbKcCWSB8GmGf+ra`D zhoVK+Dw^h$YhB|p*9o31ReoUp$i{?O;5u?;M)(!y{7svF4_jWl(6v0p7S((TP2N4; zvz0n`sh2Bfn}EfYiBPdH4B?4HX4)@b?Rzb0{5_HvhjnS|0Me#sAWlxG6XT3`pIqrw z*Pe9y$JIzsQ1?T^t{OoLp;FnC-9)V$ae`HThDEBmz$)r_(eeqXH#ytu-ESqM(kRZk zV3#ux#ECWatu_)g+39U|WQ@AmaxojEk>kRp?2N-OuJxYtGriPmX141E z41m~x_lM2#8=V<)ErJX!txC%9GN(W6*LH|l+X8`0hVA#X9Ode7_u@of*#d<37&!mM z8gu=+#Vt=Xf(6Y(NfJF$q8GL;tR*tawzI0CtZ_~f&D)YbdoI12HXT_etvx=D00Xre z7Ub(;VDg1n1X&<%zj;#6mW#YSTPGQ@YyTYhs<48`(MFiAg99HYb1?Vy<);b(lc@+Tch|bWZcd-?(GY#h z>V@r<9(Re>c}HoBCU=R*9N@Czb)TbUMx8srN|TX?wIkwoCUZOQeV8#VK=* z(5A%MhJ1J|Rt($q$NoGYf$@uh_%DTUpzQUk5-S_Np8a`tble~coKhx~z_!Xo^Nsmd zk*&Kx^tUFvx|XIko7$d+Pu!kQUS)6Ew#>)G1NeL0{ADa*c5bD7i!~@+01wu4Ywm)& z>GCBGf^SRFRe+ml6fnHFrV)XBWso4Ok`rI$LiICDvtk%A9C=~ir{n7A=Roy_A3ech z<&UCpEIn(a1?1+0GW>XfKiIck7l4F{72;nGN+_tf4W$D+9;4KWCcBc?NXCRw!1_10 z?Nb2QUJ#|Uo({zQ?)-7&`^TkJ2KXY83l*}As%OQ(3vHlZQqy;9RQJ)Xv1%ZZ6Aeb2 z=69>Z3ozphZ^<`yHDJTxWW(FxhJl5d$_}tc!hHr`c}v#rLP{UQ`X$6gKWR05eV}YM zEKnv%0+~<*Pv!xOZzm}*wzJLDl3CVpColTV3!Jy`KC2yE0nU$Q$rDVZC)Tz9KvwN` z%PMr0xtn&>EKAlMAj1P-P%f==yflnAW8Xa$*KB-Lu$UHV*Yc!yf;VW0t7sbUz&}da@}l| zeOh}YFE5U4^#YtZaHel>RW}%H06Vtw_x-0g3!>3UuMJI@+!rU(1F!fCI=>aAuiKP2 z#cYnQS5H=*wHD|j2v&3Gr^1hA{IF7e4PIMW3p<#yfOxma?_b6D11cGx{I+19ymLR< z&}F)}UYzDe<|_8=O4Zi$w&e)B^Dn=XYr_Hq>@^`RQRu3 zG^7%pt)v}TXhOu-u%Ds7Pm%TH`F6GjT>+_$ynALgnfV$O_k!Ok7HxQNT69(~FfuxN zn24Tz>FculGAx>b6-*P=#L$*#T|;Y!x*(2+Rse9qfBd};rvG?zOsXHdW^SDhV@lPi z#!k*sB(+;QNUb%>pq?RH`5m*fRsqlM>nK80Ieh>C`u9_bwqP*G+&bS)R3;YCMDES^ zGYjcNT!5(l>zZE~%m?(7eG>IvUaNuGnRI~E3p9!{yy3O~`Hi0sK-`!S+Tx_6>$g?l zy3Yl~u^YX9&hCbUz)A`TO8@yd(3%npL~V^Hma+AIPn!%z9G4J44kZ5TvJR<4^64aO zG2VO%g8D28x#*q)&_D3U*Z+DpL#E_P#d#+1sDSQHt|1)H*@9Pn9ITpbRLBLJ^=y>4l+Z%9EpoaO+MUx4O8-J-f=J(We8~{s~37`x5 z&o@UN7FEWFU1#d%TW5Uvj2d!>=YP+kFsNAe6X+!yOojJ_#DDT0f$#a}^QGePL-6q@ zW2>(79QWga$k6}K!nyHg>rr_|YBW_$ynfXO@MQjHYYxd{nn<9X-Z^noN8~)Ee$Kfv zfAW$L@_!uP9cWE_0Pzc}{$VCb8~7ss0@T)jE(O_~|GoT*W1xA!38h~Z8|DO13-DFx z|Bcpx7Jz^1{WFz(31JR!=Ewj-pWPJ>88jPL1JG&`l*Bi#BgB`^@be+NPt zWyh0+G9zL)tQLs%WG6&T#9x41|37zap$31`(SIyyU7ZHEdI~K+y6L3^>r*$Hzqsx5NtZ_z6{^` z-5GAvDS~a@?)*5~+0lp{V6IF6h6Kixpa0h@0kU~lCo!pBA{t(r7e*SP!6c%{o0__& zWoSsa92lChNOb+*mN%Ps37yoG)z^MUh}WBYi8(r8CC}rsA=L)-hxX?7Q)5nruINXZ zr*HS8%<`a(Q_@4{hXrq$Z_Dg9o4vswh|!5}%BtStL4NRQ6ni45%+wtr4(@G(alHZS zSCRsm-y@Qdlc@F)ku0-UIND7rV0mS``s&|rT)l6QX9(-BH3Z!aJ;f#XV*G2U|7$z% z?-G{#J}+sX{BPX$&qW|wtiuAM_L87CF$4ubu>bs3=%<%onYg3yY~lo9{-dU`}L*%6EEEV>*~q_p=!VX zrA68q^tM=S?YcSH5tHa^VD5uasrO{gt7Y{$hGg$htXnc+F`f7}?#Jd3|Alnq`? z=?5*ujebEQ`?2rf3(XYV4}7p2f(*sO|I>d<)@@Fgc?+Nn#nXNhc+kcegTW)^Q(m5% zE>6EmwB6vktFbNrYwkpMtBH@vzoEWuzP`493P`$@Q;()f@A%`QgidmN2@F)>Pp7$UEHn_KG{0;_!4&_wE;Dn zb>BzJY!MM%IESN-KzgkVVTZ1n-2~cKkM_?OsIcmfa{Pttd{M>ce>GdqAWMVx-6Y!Q z$YAInJ=0;XiB+(r&lq-6HX=bM{$2XFF9_lvkQ6cP`elOvTNH24j<$^gixz+zE?k-| z=gxC_45{Bu?gpOIBd8@sTpTdS7e&U1L!6BJqWN6I~ zdDC_~vdyS^iKB(-e2r`!p|WT`lOwqgtd791D(QfdgqM_`Ch<3(T?>m-+)ayQ#<|%q0YIU zfS~9!g#Bh|tFzOul|L~E!$GA=S+?f@0mFb>duHVj`4$m8Y!FBt`oLNZ)`0MHaGiPM zrIL>m?UK{LR&ux5jzJ1gLB~}aDk*R7apqjRt&H1(@)9U}*bkfi{s_U~1VTM_oH%my z`6@nD$Mwn~0>}^q3}#=$RrgA-BOt}fbH)b?z@ z$Imj;xmO4=urtPzp;D61elXX57$j3QPk>Dza31ZXIMF@w+Xr#w>hA@MC{A8BCbv#Y z8>bT$)Ls?jZs0Xwz-~32ywMu$m*7Mn$)q{TfO<4ZC+?-Y!rub3y)<1vAtYo*l$G#T zFE0fs52SjzBv#*G%d5ua(g?R7jH83SPqz|<@c^Jxylm6}=0fe%f?kB*7!jtJ>Goga z6TGe;?2OqBHj2y&k#{~$(eG@*it@EecGFBPJ2>Ml)`#%<+nf;EAhh}ZRrPX{tLmh> zl41Azs5<}wEDm5aW6KoiT;{7sHIjMR z)PL=JrowIUiPKtKl8ZY;UvnPXlBA# z?5l|^`84JtM~TA);axBZUKdWv?UBr#$D31sF-fwbs@=YwU=kU&O(eGjItBW5aXqk{ z_V%M^_e`@chN(Y+Q;)OVX5MBxS)Mc<2J=>UNa9#Acq^@V4Lt5pq+5=YXXk!an7Tfx zHtrE$02s$(hU|z~J2eOa?`g#{$3|&xuyAV#_w;dhZMKu-6dwzr4oGuWJ2R5voAGtdkb7IzuJ50OxdgB`LKxfdwKgrKj}Sn`kh1A z{LvN5Pd>7S2q-;P_D3V zvBRGD*OM`h3KTO3z5~jPQoT$$b2P#jaos}n$|VTW>mQ<=ZFDv4N-m-DCq61ltgSO9NRBaC0ZLS7JgWo=wPI?pjul8QPHV(7AnvwUn2+5Dcw z|EuOYe}~}PY-WsbB#MdRB#Q+sNTL8!ipW4#({Bp&FRa0u`5tpn#9}`Vu)OeBqKPPi zcPxTCz4mM}qN9+h(EGceWqu9BE@KtaXJO%}D@X^I3Nv?@R)>|VUm1kU*7a7f=i_!3 zob9AiE=_**&e`lJh>F2;u4!cd0PLhK*0r2oV`M0s?e?O3L$xJKnq2+XA1 zS%dTqwM})GQb0x&AR1QR6P19PIzhoRGWSG@c%YB#FES+C^Q0T@RSv!X?Ra_4du!y> z(nZ7Sx%&}~;}USfzo*z%nEJsrM~JkrDHFzXUj}5yoe{dP9;z2)JjE{h0X#lxdi-<3iX* zS3nmdBFVgNsYw6W^(E5rnU)S1WCXlp!+5y$z_Iou%9;DxCTi2n#ld;3HK2`eoE+cM zq+L`PfiM$3tleG*J1^O8>_>0YT1bzBWQ{CBPHD_aW%0FL;A3(PXRgOi8U%Z1yrur= zjGJ<+Z`}dJTt90&4Z>C2oya7-xDnl$Z%;9wC|F1ZH^LQ#)qzTj)Va(nXZeiUrF--@ z^PB*Ca^F1Y;{^qtwTLs)+r(z#JU!3Jl8k&z27?B+2<}LV6A)8`1At$CgM+Nt(Cm}_ zHNW(ev?Cg>VY0Y^`Nrcfk_4g}jH8S!ud(9RQduNN<#I$(zGirp%|mV|El&G)%@<$cW>j{ZRQBG2Y@N&hivkPH zWL~_;`x^}M_H?fc?s6d`rwou!A0ci5GXRrh{msWZ#b|5h55UU>Ba~(Qm2&js>Wg11 zR_P?ULtYy_J2e}Tt=6ZeKjSRcyQdUAyTsl#^>)|m>J{qy%%4YDA3jh3p_@Aebq{f5fQu9Bq?%eAKD%~)`yqiYAd)P? zjCpVPlN%U4g&Kj&M8zv&!jus6a`3m5;)GA3aI;Lcu`T=}#LYP3NRo`^iq{3g&1C^R zJ91Feq`#?rIW z8OQu`_{O8(!U=GCE1IL{;XW)J1e2H#u<|-bwy(cDp4>P-9#YGX9 zH-Io)Ct4rw#oE)yyZhLY1>w+srF1WDT+44HU}FD#^#zk(rcnOu#5xWGV0k0gL1_ zjKha;tJiNknDyHEfmQ^qxG{yrelPX$%H*!v8X9>A$7*jzq-C4XOb>9?{)4Pm_pBF! zl1O96NY~hzeN$_Zer4!j%nke~CKs-nH}r~Z5hzEC)%jqE7Y+3l-ZI5l?5d%bSD z8~ z9;_S?^ot#69+NH^iGGybyzUx~&oM5FCJlea9lEKQhSzjNKfIx9s}H|5JDKA~ezEeU z5o71QbuN9W2qh2-%u9l=as3(e8#N>WM*O#f3>JJ9*SN8%yby35it8n;I~<5##@4iyfbDy$G}; zdlt|w=&vq0J(RNiD(%N5!P#&#$-mqft@c7DTKvF|x8> z9{c!N|BJ=5&G`A=JMlQg*|X^-JKJu!fN1cz7tp_e~6szR?(@H_t$2;X9$w+;A~Ak;{BE4^}~Mj0Z&Ar-cz0wUO5rnksmYAqRU$q zc%FYBd%v;2X#?9>jE$19YwBS(T_>mM)9-pRbt3bsf#MI^{#l^yns* zT5UkAH4Z^wn%6aTE{R+l0A{cYQcQmPtUZ^6eqUvm;>Hvj3*3d+Q(*3 zs?GcDJ(ho_hxv3N|I^}a;aCk_#eudz@7(m3G9=|`u@?MFWS#R9Cw;nfNBJczJNF!` z2Y;+ji(l)S(y7s%o4l^$1JtHWEQ|E0UxF@|>(Yi$2_3~%kymtdUE?Ai-e|Iys)M@? zc%}4UlJT1-=Qi82VhL_JE;-tvg}tpgC`*2}B9QtwL*k5^ci^uw*Ot%2>yysr^t z$3pGU(_I%;JsnF!$&HhY_`I9>b6UUJX`_1nE^A4Cka}iyx3s#p$nc!n`JKk9qjy*a z@|muwYeyU2x-W$RHH(eZq>SUvNx z9{odGe@ot7vp;Ysi&u11JtWWTM$_H}tY7UE7qMIZjIDCa)S*%Rhdi|kl2_BzgCA@- zn)_UApZYI?nzvc&Bl(!A{Y> zPM*5ZL0ef02dESh@xImOM!dctc6iVb=esLP$i1Y$C`kRSmhZHIg7(RVn5g))Y6rm(cME?x#|ET6s@1;jA zB=cKB{voBFw~Ql@Mwva#XpfjQZIQ}DXY=18" @@ -18,7 +18,7 @@ "test": "c8 playwright test", "watch": "tsc --watch", "clean": "rm -rf lib", - "prepare": "husky" + "prepare": "" }, "files": [ "lib" @@ -62,7 +62,7 @@ }, "author": "", "bugs": { - "url": "https://github.com/mobile-next/mobile-mcp/issues" + "url": "https://github.com/QwenLM/qwen-code/issues" }, - "homepage": "https://github.com/mobile-next/mobile-mcp#readme" + "homepage": "https://github.com/QwenLM/qwen-code/tree/main/packages/mobile-mcp#readme" } diff --git a/packages/mobile-mcp/scripts/sync-from-upstream.sh b/packages/mobile-mcp/scripts/sync-from-upstream.sh new file mode 100755 index 00000000000..ee43d115a49 --- /dev/null +++ b/packages/mobile-mcp/scripts/sync-from-upstream.sh @@ -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 [] +# +# 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." diff --git a/packages/mobile-mcp/src/android.ts b/packages/mobile-mcp/src/android.ts index 8be2cc790c9..e4b185324f6 100644 --- a/packages/mobile-mcp/src/android.ts +++ b/packages/mobile-mcp/src/android.ts @@ -1,611 +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, Robot, ScreenElement, ScreenElementRect, ScreenSize, SwipeDirection, Orientation } from "./robot"; -import { validatePackageName, validateLocale } from "./utils"; +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"; + 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; + 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; - }; + 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 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 = { - "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", + 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"; +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 { - 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 { - // 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 { - 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 { - 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 { - 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 { - 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.80); - y1 = Math.floor(screenSize.height * 0.20); - break; - case "down": - x0 = x1 = centerX; - y0 = Math.floor(screenSize.height * 0.20); - y1 = Math.floor(screenSize.height * 0.80); - break; - case "left": - x0 = Math.floor(screenSize.width * 0.80); - x1 = Math.floor(screenSize.width * 0.20); - y0 = y1 = Math.floor(screenSize.height * 0.50); - break; - case "right": - x0 = Math.floor(screenSize.width * 0.20); - x1 = Math.floor(screenSize.width * 0.80); - y0 = y1 = Math.floor(screenSize.height * 0.50); - 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 { - 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 { - 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 = []; - - 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 { - const parsedXml = await this.getUiAutomatorXml(); - const hierarchy = parsedXml.hierarchy; - const elements = this.collectElements(hierarchy.node); - return elements; - } - - public async terminateApp(packageName: string): Promise { - validatePackageName(packageName); - this.adb("shell", "am", "force-stop", packageName); - } - - public async installApp(path: string): Promise { - try { - this.adb("install", "-r", 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 { - 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 { - 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 { - const packages = await this.listPackages(); - return packages.includes("com.mobilenext.devicekit"); - } - - public async sendKeys(text: string): Promise { - 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 { - this.adb("shell", "input", "tap", `${x}`, `${y}`); - } - - public async longPress(x: number, y: number, duration: number): Promise { - // 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 { - 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 { - 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 { - const rotation = this.adb("shell", "settings", "get", "system", "user_rotation").toString().trim(); - return rotation === "0" ? "portrait" : "landscape"; - } - - private async getUiAutomatorDump(): Promise { - 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 { - 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, - }; - } + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = []; + + 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 { + const parsedXml = await this.getUiAutomatorXml(); + const hierarchy = parsedXml.hierarchy; + const elements = this.collectElements(hierarchy.node); + return elements; + } + + public async terminateApp(packageName: string): Promise { + validatePackageName(packageName); + this.adb('shell', 'am', 'force-stop', packageName); + } + + public async installApp( + path: string, + options?: InstallOptions, + ): Promise { + 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 { + 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 { + 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 { + const packages = await this.listPackages(); + return packages.includes('com.mobilenext.devicekit'); + } + + public async sendKeys(text: string): Promise { + 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 { + this.adb('shell', 'input', 'tap', `${x}`, `${y}`); + } + + public async longPress( + x: number, + y: number, + duration: number, + ): Promise { + // 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 { + 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 { + 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 { + const rotation = this.adb( + 'shell', + 'settings', + 'get', + 'system', + 'user_rotation', + ) + .toString() + .trim(); + return rotation === '0' ? 'portrait' : 'landscape'; + } + + public async dumpUiHierarchy(compressed: boolean = false): Promise { + 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(' { + 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 { + 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 { - 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 []; - } - } + 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 []; + } + } } diff --git a/packages/mobile-mcp/src/coord-norm.ts b/packages/mobile-mcp/src/coord-norm.ts new file mode 100644 index 00000000000..7f9abb6e650 --- /dev/null +++ b/packages/mobile-mcp/src/coord-norm.ts @@ -0,0 +1,180 @@ +// 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 = { + 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(); + +export function cacheScreenSize( + deviceId: string, + width: number, + height: number, +): void { + screenSizeCache.set(deviceId, { width, height }); +} + +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, + 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 { + 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(/in pixels/g, `in 0-${scale} normalized coordinates`) + .replace(/\bin pixels\b/g, `in 0-${scale} normalized coordinates`); +} + +export function coordParamDesc(baseDesc: string): string { + if (!isNormalized()) return baseDesc; + return rewriteDescription(baseDesc); +} diff --git a/packages/mobile-mcp/src/ios.ts b/packages/mobile-mcp/src/ios.ts index 382b6d97765..e17149b81b3 100644 --- a/packages/mobile-mcp/src/ios.ts +++ b/packages/mobile-mcp/src/ios.ts @@ -1,227 +1,254 @@ -import { Socket } from "node:net"; -import { execFileSync } from "node:child_process"; - -import { WebDriverAgent } from "./webdriver-agent"; -import { ActionableError, Button, InstalledApp, Robot, ScreenSize, SwipeDirection, ScreenElement, Orientation } from "./robot"; -import { validatePackageName, validateLocale } from "./utils"; +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[]; + deviceList: string[]; } interface VersionCommandOutput { - version: string; + version: string; } interface InfoCommandOutput { - DeviceClass: string; - DeviceName: string; - ProductName: string; - ProductType: string; - ProductVersion: string; - PhoneNumber: string; - TimeZone: string; + DeviceClass: string; + DeviceName: string; + ProductName: string; + ProductType: string; + ProductVersion: string; + PhoneNumber: string; + TimeZone: string; } export interface IosDevice { - deviceId: string; - deviceName: string; + deviceId: string; + deviceName: string; } const getGoIosPath = (): string => { - if (process.env.GO_IOS_PATH) { - return process.env.GO_IOS_PATH; - } + 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"; + // 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 { - 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 { - return await this.isListeningOnPort(IOS_TUNNEL_PORT); - } - - private async isWdaForwardRunning(): Promise { - return await this.isListeningOnPort(WDA_PORT); - } - - private async assertTunnelRunning(): Promise { - 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 { - - 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 { - return execFileSync(getGoIosPath(), ["--udid", this.deviceId, ...args], {}).toString(); - } - - public async getIosVersion(): Promise { - const output = await this.ios("info"); - const json = JSON.parse(output); - return json.ProductVersion; - } - - private async isTunnelRequired(): Promise { - const version = await this.getIosVersion(); - const args = version.split("."); - return parseInt(args[0], 10) >= 17; - } - - public async getScreenSize(): Promise { - const wda = await this.wda(); - return await wda.getScreenSize(); - } - - public async swipe(direction: SwipeDirection): Promise { - const wda = await this.wda(); - await wda.swipe(direction); - } - - public async swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance?: number): Promise { - const wda = await this.wda(); - await wda.swipeFromCoordinate(x, y, direction, distance); - } - - public async listApps(): Promise { - 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 { - 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 { - validatePackageName(packageName); - await this.assertTunnelRunning(); - await this.ios("kill", packageName); - } - - public async installApp(path: string): Promise { - 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 { - 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 { - const wda = await this.wda(); - await wda.openUrl(url); - } - - public async sendKeys(text: string): Promise { - const wda = await this.wda(); - await wda.sendKeys(text); - } - - public async pressButton(button: Button): Promise { - const wda = await this.wda(); - await wda.pressButton(button); - } - - public async tap(x: number, y: number): Promise { - const wda = await this.wda(); - await wda.tap(x, y); - } - - public async doubleTap(x: number, y: number): Promise { - const wda = await this.wda(); - await wda.doubleTap(x, y); - } - - public async longPress(x: number, y: number, duration: number): Promise { - const wda = await this.wda(); - await wda.longPress(x, y, duration); - } - - public async getElementsOnScreen(): Promise { - const wda = await this.wda(); - return await wda.getElementsOnScreen(); - } - - public async getScreenshot(): Promise { - const wda = await this.wda(); - return await wda.getScreenshot(); - - /* alternative: + public constructor(private deviceId: string) {} + + private isListeningOnPort(port: number): Promise { + 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 { + return await this.isListeningOnPort(IOS_TUNNEL_PORT); + } + + private async isWdaForwardRunning(): Promise { + return await this.isListeningOnPort(WDA_PORT); + } + + private async assertTunnelRunning(): Promise { + 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 { + 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 { + return execFileSync( + getGoIosPath(), + ['--udid', this.deviceId, ...args], + {}, + ).toString(); + } + + public async getIosVersion(): Promise { + const output = await this.ios('info'); + const json = JSON.parse(output); + return json.ProductVersion; + } + + private async isTunnelRequired(): Promise { + const version = await this.getIosVersion(); + const args = version.split('.'); + return parseInt(args[0], 10) >= 17; + } + + public async getScreenSize(): Promise { + const wda = await this.wda(); + return await wda.getScreenSize(); + } + + public async swipe(direction: SwipeDirection): Promise { + const wda = await this.wda(); + await wda.swipe(direction); + } + + public async swipeFromCoordinate( + x: number, + y: number, + direction: SwipeDirection, + distance?: number, + ): Promise { + const wda = await this.wda(); + await wda.swipeFromCoordinate(x, y, direction, distance); + } + + public async listApps(): Promise { + 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 { + 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 { + validatePackageName(packageName); + await this.assertTunnelRunning(); + await this.ios('kill', packageName); + } + + public async installApp( + path: string, + _options?: InstallOptions, + ): Promise { + 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 { + 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 { + const wda = await this.wda(); + await wda.openUrl(url); + } + + public async sendKeys(text: string): Promise { + const wda = await this.wda(); + await wda.sendKeys(text); + } + + public async pressButton(button: Button): Promise { + const wda = await this.wda(); + await wda.pressButton(button); + } + + public async tap(x: number, y: number): Promise { + const wda = await this.wda(); + await wda.tap(x, y); + } + + public async doubleTap(x: number, y: number): Promise { + const wda = await this.wda(); + await wda.doubleTap(x, y); + } + + public async longPress( + x: number, + y: number, + duration: number, + ): Promise { + const wda = await this.wda(); + await wda.longPress(x, y, duration); + } + + public async getElementsOnScreen(): Promise { + const wda = await this.wda(); + return await wda.getElementsOnScreen(); + } + + public async getScreenshot(): Promise { + 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); @@ -229,76 +256,93 @@ export class IosRobot implements Robot { unlinkSync(tmpFilename); return buffer; */ - } + } - public async setOrientation(orientation: Orientation): Promise { - const wda = await this.wda(); - await wda.setOrientation(orientation); - } + public async setOrientation(orientation: Orientation): Promise { + const wda = await this.wda(); + await wda.setOrientation(orientation); + } - public async getOrientation(): Promise { - const wda = await this.wda(); - return await wda.getOrientation(); - } + public async getOrientation(): Promise { + 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 { - 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; - } + 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 { + 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; + } } diff --git a/packages/mobile-mcp/src/iphone-simulator.ts b/packages/mobile-mcp/src/iphone-simulator.ts index ef95f0e27b8..b9854ad841e 100644 --- a/packages/mobile-mcp/src/iphone-simulator.ts +++ b/packages/mobile-mcp/src/iphone-simulator.ts @@ -1,29 +1,39 @@ -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, Robot, ScreenElement, ScreenSize, SwipeDirection, Orientation } from "./robot"; -import { validatePackageName, validateLocale } from "./utils"; +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; + 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; + ApplicationType: string; + Bundle: string; + CFBundleDisplayName: string; + CFBundleExecutable: string; + CFBundleIdentifier: string; + CFBundleName: string; + CFBundleVersion: string; + DataContainer: string; + Path: string; } const TIMEOUT = 30000; @@ -31,253 +41,272 @@ 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 { - const apps = await this.listApps(); - return apps.map(app => app.packageName).includes("com.facebook.WebDriverAgentRunner.xctrunner"); - } - - private async startWda(): Promise { - 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 { - 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 { - 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): Promise { - 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 { - 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 { - 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; - return Object.values(output).map(app => ({ - packageName: app.CFBundleIdentifier, - appName: app.CFBundleDisplayName, - })); - } - - public async getScreenSize(): Promise { - 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 { - const wda = await this.wda(); - return wda.swipe(direction); - } - - public async swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance?: number): Promise { - 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 { - 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 { - const wda = await this.wda(); - return wda.getElementsOnScreen(); - } - - public async setOrientation(orientation: Orientation): Promise { - const wda = await this.wda(); - return wda.setOrientation(orientation); - } - - public async getOrientation(): Promise { - const wda = await this.wda(); - return wda.getOrientation(); - } + constructor(private readonly simulatorUuid: string) {} + + private async isWdaInstalled(): Promise { + const apps = await this.listApps(); + return apps + .map((app) => app.packageName) + .includes('com.facebook.WebDriverAgentRunner.xctrunner'); + } + + private async startWda(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + return Object.values(output).map((app) => ({ + packageName: app.CFBundleIdentifier, + appName: app.CFBundleDisplayName, + })); + } + + public async getScreenSize(): Promise { + 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 { + const wda = await this.wda(); + return wda.swipe(direction); + } + + public async swipeFromCoordinate( + x: number, + y: number, + direction: SwipeDirection, + distance?: number, + ): Promise { + 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 { + 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 { + const wda = await this.wda(); + return wda.getElementsOnScreen(); + } + + public async setOrientation(orientation: Orientation): Promise { + const wda = await this.wda(); + return wda.setOrientation(orientation); + } + + public async getOrientation(): Promise { + const wda = await this.wda(); + return wda.getOrientation(); + } } diff --git a/packages/mobile-mcp/src/mobile-device.ts b/packages/mobile-mcp/src/mobile-device.ts index b0babaa5f3d..49a6c3f4a5b 100644 --- a/packages/mobile-mcp/src/mobile-device.ts +++ b/packages/mobile-mcp/src/mobile-device.ts @@ -1,221 +1,263 @@ -import { Mobilecli } from "./mobilecli"; -import { Button, InstalledApp, Orientation, Robot, ScreenElement, ScreenSize, SwipeDirection } from "./robot"; +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 - }>; + 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; - }; - }; - }; + 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; + 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[]; - }; + status: 'ok'; + data: { + elements: UIElementResponse[]; + }; } interface OrientationResponse { - status: "ok", - data: { - orientation: Orientation; - }; + 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 { - 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 { - 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 { - 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 { - const fullArgs = ["screenshot", "--device", this.deviceId, "--format", "png", "--output", "-"]; - return this.mobilecli.executeCommandBuffer(fullArgs); - } - - public async listApps(): Promise { - 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 { - const args = ["apps", "launch", packageName]; - if (locale) { - args.push("--locale", locale); - } - - this.runCommand(args); - } - - public async terminateApp(packageName: string): Promise { - this.runCommand(["apps", "terminate", packageName]); - } - - public async installApp(path: string): Promise { - this.runCommand(["apps", "install", path]); - } - - public async uninstallApp(bundleId: string): Promise { - this.runCommand(["apps", "uninstall", bundleId]); - } - - public async openUrl(url: string): Promise { - this.runCommand(["url", url]); - } - - public async sendKeys(text: string): Promise { - this.runCommand(["io", "text", text]); - } - - public async pressButton(button: Button): Promise { - this.runCommand(["io", "button", button]); - } - - public async tap(x: number, y: number): Promise { - this.runCommand(["io", "tap", `${x},${y}`]); - } - - public async doubleTap(x: number, y: number): Promise { - // 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 { - this.runCommand(["io", "longpress", `${x},${y}`, "--duration", `${duration}`]); - } - - public async getElementsOnScreen(): Promise { - 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 { - this.runCommand(["device", "orientation", "set", orientation]); - } - - public async getOrientation(): Promise { - const response = JSON.parse(this.runCommand(["device", "orientation", "get"])) as OrientationResponse; - return response.data.orientation; - } + 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 { + 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 { + 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 { + 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 { + const fullArgs = [ + 'screenshot', + '--device', + this.deviceId, + '--format', + 'png', + '--output', + '-', + ]; + return this.mobilecli.executeCommandBuffer(fullArgs); + } + + public async listApps(): Promise { + 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 { + const args = ['apps', 'launch', packageName]; + if (locale) { + args.push('--locale', locale); + } + + this.runCommand(args); + } + + public async terminateApp(packageName: string): Promise { + this.runCommand(['apps', 'terminate', packageName]); + } + + public async installApp( + path: string, + _options?: InstallOptions, + ): Promise { + this.runCommand(['apps', 'install', path]); + } + + public async uninstallApp(bundleId: string): Promise { + this.runCommand(['apps', 'uninstall', bundleId]); + } + + public async openUrl(url: string): Promise { + this.runCommand(['url', url]); + } + + public async sendKeys(text: string): Promise { + this.runCommand(['io', 'text', text]); + } + + public async pressButton(button: Button): Promise { + this.runCommand(['io', 'button', button]); + } + + public async tap(x: number, y: number): Promise { + this.runCommand(['io', 'tap', `${x},${y}`]); + } + + public async doubleTap(x: number, y: number): Promise { + // 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 { + this.runCommand([ + 'io', + 'longpress', + `${x},${y}`, + '--duration', + `${duration}`, + ]); + } + + public async getElementsOnScreen(): Promise { + 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 { + this.runCommand(['device', 'orientation', 'set', orientation]); + } + + public async getOrientation(): Promise { + const response = JSON.parse( + this.runCommand(['device', 'orientation', 'get']), + ) as OrientationResponse; + return response.data.orientation; + } } diff --git a/packages/mobile-mcp/src/robot.ts b/packages/mobile-mcp/src/robot.ts index 59bb344dfc4..1c0e1e1cf79 100644 --- a/packages/mobile-mcp/src/robot.ts +++ b/packages/mobile-mcp/src/robot.ts @@ -1,147 +1,172 @@ export interface Dimensions { - width: number; - height: number; + width: number; + height: number; } export interface ScreenSize extends Dimensions { - scale: number; + scale: number; } export interface InstalledApp { - packageName: string; - appName: string; + packageName: string; + appName: string; } -export type SwipeDirection = "up" | "down" | "left" | "right"; +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 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; + 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; + 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); - } + 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 type Orientation = 'portrait' | 'landscape'; export interface Robot { - /** - * Get the screen size of the device in pixels. - */ - getScreenSize(): Promise; - - /** - * Swipe in a direction. - */ - swipe(direction: SwipeDirection): Promise; - - /** - * Swipe from a specific coordinate in a direction. - */ - swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance?: number): Promise; - - /** - * 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; - - /** - * List all installed apps on the device. Returns an array of package names (or - * bundle identifiers in iOS) for all installed apps. - */ - listApps(): Promise; - - /** - * Launch an app. - */ - launchApp(packageName: string, locale?: string): Promise; - - /** - * Terminate an app. If app was already terminated (or non existent) then this - * is a no-op. - */ - terminateApp(packageName: string): Promise; - - /** - * Install an app on the device from a file path. - */ - installApp(path: string): Promise; - - /** - * Uninstall an app from the device. - */ - uninstallApp(bundleId: string): Promise; - - /** - * 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; - - /** - * Send keys to the device, simulating keyboard input. - */ - sendKeys(text: string): Promise; - - /** - * Press a button on the device, simulating a physical button press. - */ - pressButton(button: Button): Promise; - - /** - * Tap on a specific coordinate on the screen. - */ - tap(x: number, y: number): Promise; - - /** - * Tap on a specific coordinate on the screen. - */ - doubleTap(x: number, y: number): Promise; - - /** - * 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; - - /** - * 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; - - /** - * Change the screen orientation of the device. - * @param orientation The desired orientation ("portrait" or "landscape") - */ - setOrientation(orientation: Orientation): Promise; - - /** - * Get the current screen orientation. - */ - getOrientation(): Promise; + /** + * Get the screen size of the device in pixels. + */ + getScreenSize(): Promise; + + /** + * Swipe in a direction. + */ + swipe(direction: SwipeDirection): Promise; + + /** + * Swipe from a specific coordinate in a direction. + */ + swipeFromCoordinate( + x: number, + y: number, + direction: SwipeDirection, + distance?: number, + ): Promise; + + /** + * 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; + + /** + * List all installed apps on the device. Returns an array of package names (or + * bundle identifiers in iOS) for all installed apps. + */ + listApps(): Promise; + + /** + * Launch an app. + */ + launchApp(packageName: string, locale?: string): Promise; + + /** + * Terminate an app. If app was already terminated (or non existent) then this + * is a no-op. + */ + terminateApp(packageName: string): Promise; + + /** + * Install an app on the device from a file path. + */ + installApp(path: string, options?: InstallOptions): Promise; + + /** + * Uninstall an app from the device. + */ + uninstallApp(bundleId: string): Promise; + + /** + * 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; + + /** + * Send keys to the device, simulating keyboard input. + */ + sendKeys(text: string): Promise; + + /** + * Press a button on the device, simulating a physical button press. + */ + pressButton(button: Button): Promise; + + /** + * Tap on a specific coordinate on the screen. + */ + tap(x: number, y: number): Promise; + + /** + * Tap on a specific coordinate on the screen. + */ + doubleTap(x: number, y: number): Promise; + + /** + * 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; + + /** + * 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; + + /** + * Change the screen orientation of the device. + * @param orientation The desired orientation ("portrait" or "landscape") + */ + setOrientation(orientation: Orientation): Promise; + + /** + * Get the current screen orientation. + */ + getOrientation(): Promise; } diff --git a/packages/mobile-mcp/src/server.ts b/packages/mobile-mcp/src/server.ts index 8de6604cf47..4ac66b91463 100644 --- a/packages/mobile-mcp/src/server.ts +++ b/packages/mobile-mcp/src/server.ts @@ -1,844 +1,1322 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import crypto from "node:crypto"; -import { ChildProcess } from "node:child_process"; - -import { error, trace } from "./logger"; -import { AndroidRobot, AndroidDeviceManager } from "./android"; -import { ActionableError, Robot } from "./robot"; -import { IosManager, IosRobot } from "./ios"; -import { PNG } from "./png"; -import { isScalingAvailable, Image } from "./image-utils"; -import { Mobilecli } from "./mobilecli"; -import { MobileDevice } from "./mobile-device"; -import { validateOutputPath, validateFileExtension } from "./utils"; - -const ALLOWED_SCREENSHOT_EXTENSIONS = [".png", ".jpg", ".jpeg"]; -const ALLOWED_RECORDING_EXTENSIONS = [".mp4"]; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { ChildProcess } from 'node:child_process'; + +import { error, trace } from './logger'; +import { AndroidRobot, AndroidDeviceManager } from './android'; +import { ActionableError, Robot, InstallOptions } from './robot'; +import { IosManager, IosRobot } from './ios'; +import { PNG } from './png'; +import { isScalingAvailable, Image } from './image-utils'; +import { Mobilecli } from './mobilecli'; +import { MobileDevice } from './mobile-device'; +import { validateOutputPath, validateFileExtension } from './utils'; +import { + isNormalized, + coordinateScale, + denormalizeArgs, + normalizeElementResult, + normalizeScreenSizeResult, + ingestScreenSizeFromResult, + getCachedScreenSize, + cacheScreenSize, + rewriteDescription, + coordParamDesc, +} from './coord-norm'; + +const ALLOWED_SCREENSHOT_EXTENSIONS = ['.png', '.jpg', '.jpeg']; +const ALLOWED_RECORDING_EXTENSIONS = ['.mp4']; interface MobilecliDevice { - id: string; - name: string; - platform: "android" | "ios"; - type: "real" | "emulator" | "simulator"; - version: string; - state: "online" | "offline"; + id: string; + name: string; + platform: 'android' | 'ios'; + type: 'real' | 'emulator' | 'simulator'; + version: string; + state: 'online' | 'offline'; } interface MobilecliDevicesResponse { - devices: MobilecliDevice[]; + devices: MobilecliDevice[]; } interface ActiveRecording { - process: ChildProcess; - outputPath: string; - startedAt: number; + process: ChildProcess; + outputPath: string; + startedAt: number; } export const getAgentVersion = (): string => { - const json = require("../package.json"); - return json.version; + const json = require('../package.json'); + return json.version; }; export const createMcpServer = (): McpServer => { - - const server = new McpServer({ - name: "mobile-mcp", - version: getAgentVersion(), - }); - - - const getClientName = (): string => { - try { - const clientInfo = server.server.getClientVersion(); - const clientName = clientInfo?.name || "unknown"; - return clientName; - } catch (error: any) { - return "unknown"; - } - }; - - type ZodSchemaShape = Record; - - interface ToolAnnotations { - readOnlyHint?: boolean; - destructiveHint?: boolean; - } - - const tool = (name: string, title: string, description: string, paramsSchema: ZodSchemaShape, annotations: ToolAnnotations, cb: (args: any) => Promise) => { - server.registerTool(name, { - title, - description, - inputSchema: paramsSchema, - annotations, - }, (async (args: any, _extra: any) => { - try { - trace(`Invoking ${name} with args: ${JSON.stringify(args)}`); - const start = +new Date(); - const response = await cb(args); - const duration = +new Date() - start; - trace(`=> ${response}`); - posthog("tool_invoked", { "ToolName": name, "Duration": duration }).then(); - return { - content: [{ type: "text", text: response }], - }; - } catch (error: any) { - posthog("tool_failed", { "ToolName": name }).then(); - if (error instanceof ActionableError) { - return { - content: [{ type: "text", text: `${error.message}. Please fix the issue and try again.` }], - }; - } else { - // a real exception - trace(`Tool '${description}' failed: ${error.message} stack: ${error.stack}`); - return { - content: [{ type: "text", text: `Error: ${error.message}` }], - isError: true, - }; - } - } - }) as any); - }; - - const posthog = async (event: string, properties: Record) => { - if (process.env.MOBILEMCP_DISABLE_TELEMETRY) { - return; - } - - try { - const url = "https://us.i.posthog.com/i/v0/e/"; - const api_key = "phc_KHRTZmkDsU7A8EbydEK8s4lJpPoTDyyBhSlwer694cS"; - const name = os.hostname() + process.execPath; - const distinct_id = crypto.createHash("sha256").update(name).digest("hex"); - const systemProps: any = { - Platform: os.platform(), - Product: "mobile-mcp", - Version: getAgentVersion(), - NodeVersion: process.version, - CI: process.env.CI || "0", - }; - - const clientName = getClientName(); - if (clientName !== "unknown") { - systemProps.AgentName = clientName; - } - - await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - api_key, - event, - properties: { - ...systemProps, - ...properties, - }, - distinct_id, - }) - }); - } catch (err: any) { - // ignore - } - }; - - const mobilecli = new Mobilecli(); - const activeRecordings = new Map(); - const agentVerifiedSimulators = new Set(); - posthog("launch", {}).then(); - - const ensureMobilecliAvailable = (): void => { - try { - const version = mobilecli.getVersion(); - if (version.startsWith("failed")) { - throw new Error("mobilecli version check failed"); - } - } catch (error: any) { - throw new ActionableError(`mobilecli is not available or not working properly. Please review the documentation at https://github.com/mobile-next/mobile-mcp/wiki for installation instructions`); - } - }; - - const getRobotFromDevice = (deviceId: string): Robot => { - - // from now on, we must have mobilecli working - ensureMobilecliAvailable(); - - // Check if it's an iOS device - const iosManager = new IosManager(); - const iosDevices = iosManager.listDevices(); - const iosDevice = iosDevices.find(d => d.deviceId === deviceId); - if (iosDevice) { - posthog("get_robot", { "DevicePlatform": "ios", "DeviceType": "real" }).then(); - return new IosRobot(deviceId); - } - - // Check if it's an Android device - const androidManager = new AndroidDeviceManager(); - const androidDevices = androidManager.getConnectedDevices(); - const androidDevice = androidDevices.find(d => d.deviceId === deviceId); - if (androidDevice) { - posthog("get_robot", { "DevicePlatform": "android" }).then(); - return new AndroidRobot(deviceId); - } - - // Check if it's a simulator (will later replace all other device types as well) - const response = mobilecli.getDevices({ - platform: "ios", - type: "simulator", - includeOffline: false, - }); - - if (response.status === "ok" && response.data && response.data.devices) { - for (const device of response.data.devices) { - if (device.id === deviceId) { - if (!agentVerifiedSimulators.has(deviceId)) { - const agentStatus = mobilecli.agentStatus(deviceId); - if (agentStatus.status === "fail") { - mobilecli.agentInstall(deviceId); - } - - agentVerifiedSimulators.add(deviceId); - } - - posthog("get_robot", { "DevicePlatform": "ios", "DeviceType": "simulator" }).then(); - return new MobileDevice(deviceId); - } - } - } - - throw new ActionableError(`Device "${deviceId}" not found. Use the mobile_list_available_devices tool to see available devices.`); - }; - - tool( - "mobile_list_available_devices", - "List Devices", - "List all available devices. This includes both physical mobile devices and mobile simulators and emulators. It returns both Android and iOS devices.", - {}, - { readOnlyHint: true }, - async ({}) => { - - // from today onward, we must have mobilecli working - ensureMobilecliAvailable(); - - const iosManager = new IosManager(); - const androidManager = new AndroidDeviceManager(); - const devices: MobilecliDevice[] = []; - - // Get Android devices with details - const androidDevices = androidManager.getConnectedDevicesWithDetails(); - for (const device of androidDevices) { - devices.push({ - id: device.deviceId, - name: device.name, - platform: "android", - type: "emulator", - version: device.version, - state: "online", - }); - } - - // Get iOS physical devices with details - try { - const iosDevices = iosManager.listDevicesWithDetails(); - for (const device of iosDevices) { - devices.push({ - id: device.deviceId, - name: device.deviceName, - platform: "ios", - type: "real", - version: device.version, - state: "online", - }); - } - } catch (error: any) { - // If go-ios is not available, silently skip - } - - // Get iOS simulators from mobilecli (excluding offline devices) - const response = mobilecli.getDevices({ - platform: "ios", - type: "simulator", - includeOffline: false, - }); - if (response.status === "ok" && response.data && response.data.devices) { - for (const device of response.data.devices) { - devices.push({ - id: device.id, - name: device.name, - platform: device.platform, - type: device.type, - version: device.version, - state: "online", - }); - } - } - - const out: MobilecliDevicesResponse = { devices }; - return JSON.stringify(out); - } - ); - - if (process.env.MOBILEFLEET_ENABLE === "1") { - tool( - "mobile_list_remote_devices", - "List Remote Devices", - "List devices available in the remote fleet", - {}, - { readOnlyHint: true }, - async ({}) => { - ensureMobilecliAvailable(); - const result = mobilecli.remoteListDevices(); - return result; - } - ); - - tool( - "mobile_allocate_remote_device", - "Allocate Remote Device", - "Reserve a device from the remote fleet", - { - platform: z.enum(["ios", "android"]).describe("The platform to allocate a device for"), - }, - { destructiveHint: true }, - async ({ platform }) => { - ensureMobilecliAvailable(); - const result = mobilecli.remoteAllocate(platform); - return result; - } - ); - - tool( - "mobile_release_remote_device", - "Release Remote Device", - "Release a device back to the remote fleet", - { - device: z.string().describe("The device identifier to release back to the remote fleet"), - }, - { destructiveHint: true }, - async ({ device }) => { - ensureMobilecliAvailable(); - const result = mobilecli.remoteRelease(device); - return result; - } - ); - } - - tool( - "mobile_list_apps", - "List Apps", - "List all the installed apps on the device", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.") - }, - { readOnlyHint: true }, - async ({ device }) => { - const robot = getRobotFromDevice(device); - const result = await robot.listApps(); - return `Found these apps on device: ${result.map(app => `${app.appName} (${app.packageName})`).join(", ")}`; - } - ); - - tool( - "mobile_launch_app", - "Launch App", - "Launch an app on mobile device. Use this to open a specific app. You can find the package name of the app by calling list_apps_on_device.", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - packageName: z.string().describe("The package name of the app to launch"), - locale: z.string().optional().describe("Comma-separated BCP 47 locale tags to launch the app with (e.g., fr-FR,en-GB)"), - }, - { destructiveHint: true }, - async ({ device, packageName, locale }) => { - const robot = getRobotFromDevice(device); - await robot.launchApp(packageName, locale); - return `Launched app ${packageName}`; - } - ); - - tool( - "mobile_terminate_app", - "Terminate App", - "Stop and terminate an app on mobile device", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - packageName: z.string().describe("The package name of the app to terminate"), - }, - { destructiveHint: true }, - async ({ device, packageName }) => { - const robot = getRobotFromDevice(device); - await robot.terminateApp(packageName); - return `Terminated app ${packageName}`; - } - ); - - tool( - "mobile_install_app", - "Install App", - "Install an app on mobile device", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - path: z.string().describe("The path to the app file to install. For iOS simulators, provide a .zip file or a .app directory. For Android provide an .apk file. For iOS real devices provide an .ipa file"), - }, - { destructiveHint: true }, - async ({ device, path }) => { - const robot = getRobotFromDevice(device); - await robot.installApp(path); - return `Installed app from ${path}`; - } - ); - - tool( - "mobile_uninstall_app", - "Uninstall App", - "Uninstall an app from mobile device", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - bundle_id: z.string().describe("Bundle identifier (iOS) or package name (Android) of the app to be uninstalled"), - }, - { destructiveHint: true }, - async ({ device, bundle_id }) => { - const robot = getRobotFromDevice(device); - await robot.uninstallApp(bundle_id); - return `Uninstalled app ${bundle_id}`; - } - ); - - tool( - "mobile_get_screen_size", - "Get Screen Size", - "Get the screen size of the mobile device in pixels", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.") - }, - { readOnlyHint: true }, - async ({ device }) => { - const robot = getRobotFromDevice(device); - const screenSize = await robot.getScreenSize(); - return `Screen size is ${screenSize.width}x${screenSize.height} pixels`; - } - ); - - tool( - "mobile_click_on_screen_at_coordinates", - "Click Screen", - "Click on the screen at given x,y coordinates. If clicking on an element, use the list_elements_on_screen tool to find the coordinates.", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - x: z.coerce.number().describe("The x coordinate to click on the screen, in pixels"), - y: z.coerce.number().describe("The y coordinate to click on the screen, in pixels"), - }, - { destructiveHint: true }, - async ({ device, x, y }) => { - const robot = getRobotFromDevice(device); - await robot.tap(x, y); - return `Clicked on screen at coordinates: ${x}, ${y}`; - } - ); - - tool( - "mobile_double_tap_on_screen", - "Double Tap Screen", - "Double-tap on the screen at given x,y coordinates.", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - x: z.coerce.number().describe("The x coordinate to double-tap, in pixels"), - y: z.coerce.number().describe("The y coordinate to double-tap, in pixels"), - }, - { destructiveHint: true }, - async ({ device, x, y }) => { - const robot = getRobotFromDevice(device); - await robot!.doubleTap(x, y); - return `Double-tapped on screen at coordinates: ${x}, ${y}`; - } - ); - - tool( - "mobile_long_press_on_screen_at_coordinates", - "Long Press Screen", - "Long press on the screen at given x,y coordinates. If long pressing on an element, use the list_elements_on_screen tool to find the coordinates.", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - x: z.coerce.number().describe("The x coordinate to long press on the screen, in pixels"), - y: z.coerce.number().describe("The y coordinate to long press on the screen, in pixels"), - duration: z.coerce.number().min(1).max(10000).optional().describe("Duration of the long press in milliseconds. Defaults to 500ms."), - }, - { destructiveHint: true }, - async ({ device, x, y, duration }) => { - const robot = getRobotFromDevice(device); - const pressDuration = duration ?? 500; - await robot.longPress(x, y, pressDuration); - return `Long pressed on screen at coordinates: ${x}, ${y} for ${pressDuration}ms`; - } - ); - - tool( - "mobile_list_elements_on_screen", - "List Screen Elements", - "List elements on screen and their coordinates, with display text or accessibility label. Do not cache this result.", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.") - }, - { readOnlyHint: true }, - async ({ device }) => { - const robot = getRobotFromDevice(device); - const elements = await robot.getElementsOnScreen(); - - const result = elements.map(element => { - const out: any = { - type: element.type, - text: element.text, - label: element.label, - name: element.name, - value: element.value, - identifier: element.identifier, - coordinates: { - x: element.rect.x, - y: element.rect.y, - width: element.rect.width, - height: element.rect.height, - }, - }; - - if (element.focused) { - out.focused = true; - } - - return out; - }); - - return `Found these elements on screen: ${JSON.stringify(result)}`; - } - ); - - tool( - "mobile_press_button", - "Press Button", - "Press a button on device", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - button: z.string().describe("The button to press. Supported buttons: BACK (android only), HOME, VOLUME_UP, VOLUME_DOWN, ENTER, DPAD_CENTER (android tv only), DPAD_UP (android tv only), DPAD_DOWN (android tv only), DPAD_LEFT (android tv only), DPAD_RIGHT (android tv only)"), - }, - { destructiveHint: true }, - async ({ device, button }) => { - const robot = getRobotFromDevice(device); - await robot.pressButton(button); - return `Pressed the button: ${button}`; - } - ); - - tool( - "mobile_open_url", - "Open URL", - "Open a URL in browser on device", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - url: z.string().describe("The URL to open"), - }, - { destructiveHint: true }, - async ({ device, url }) => { - const allowUnsafeUrls = process.env.MOBILEMCP_ALLOW_UNSAFE_URLS === "1"; - if (!allowUnsafeUrls && !url.startsWith("http://") && !url.startsWith("https://")) { - throw new ActionableError("Only http:// and https:// URLs are allowed. Set MOBILEMCP_ALLOW_UNSAFE_URLS=1 to allow other URL schemes."); - } - - const robot = getRobotFromDevice(device); - await robot.openUrl(url); - return `Opened URL: ${url}`; - } - ); - - tool( - "mobile_swipe_on_screen", - "Swipe Screen", - "Swipe on the screen", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - direction: z.enum(["up", "down", "left", "right"]).describe("The direction to swipe"), - x: z.coerce.number().optional().describe("The x coordinate to start the swipe from, in pixels. If not provided, uses center of screen"), - y: z.coerce.number().optional().describe("The y coordinate to start the swipe from, in pixels. If not provided, uses center of screen"), - distance: z.coerce.number().optional().describe("The distance to swipe in pixels. Defaults to 400 pixels for iOS or 30% of screen dimension for Android"), - }, - { destructiveHint: true }, - async ({ device, direction, x, y, distance }) => { - const robot = getRobotFromDevice(device); - - if (x !== undefined && y !== undefined) { - // Use coordinate-based swipe - await robot.swipeFromCoordinate(x, y, direction, distance); - const distanceText = distance ? ` ${distance} pixels` : ""; - return `Swiped ${direction}${distanceText} from coordinates: ${x}, ${y}`; - } else { - // Use center-based swipe - await robot.swipe(direction); - return `Swiped ${direction} on screen`; - } - } - ); - - tool( - "mobile_type_keys", - "Type Text", - "Type text into the focused element", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - text: z.string().describe("The text to type"), - submit: z.boolean().describe("Whether to submit the text. If true, the text will be submitted as if the user pressed the enter key."), - }, - { destructiveHint: true }, - async ({ device, text, submit }) => { - const robot = getRobotFromDevice(device); - await robot.sendKeys(text); - - if (submit) { - await robot.pressButton("ENTER"); - } - - return `Typed text: ${text}`; - } - ); - - tool( - "mobile_save_screenshot", - "Save Screenshot", - "Save a screenshot of the mobile device to a file", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - saveTo: z.string().describe("The path to save the screenshot to. Filename must end with .png, .jpg, or .jpeg"), - }, - { destructiveHint: true }, - async ({ device, saveTo }) => { - validateFileExtension(saveTo, ALLOWED_SCREENSHOT_EXTENSIONS, "save_screenshot"); - validateOutputPath(saveTo); - - const robot = getRobotFromDevice(device); - - const screenshot = await robot.getScreenshot(); - fs.writeFileSync(saveTo, screenshot); - return `Screenshot saved to: ${saveTo}`; - } - ); - - server.registerTool( - "mobile_take_screenshot", - { - title: "Take Screenshot", - description: "Take a screenshot of the mobile device. Use this to understand what's on screen, if you need to press an element that is available through view hierarchy then you must list elements on screen instead. Do not cache this result.", - inputSchema: { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.") - }, - annotations: { - readOnlyHint: true, - }, - }, - async ({ device }) => { - try { - const robot = getRobotFromDevice(device); - const screenSize = await robot.getScreenSize(); - - let screenshot = await robot.getScreenshot(); - let mimeType = "image/png"; - - // validate we received a png, will throw exception otherwise - const image = new PNG(screenshot); - const pngSize = image.getDimensions(); - if (pngSize.width <= 0 || pngSize.height <= 0) { - throw new ActionableError("Screenshot is invalid. Please try again."); - } - - if (isScalingAvailable()) { - trace("Image scaling is available, resizing screenshot"); - const image = Image.fromBuffer(screenshot); - const beforeSize = screenshot.length; - screenshot = image.resize(Math.floor(pngSize.width / screenSize.scale)) - .jpeg({ quality: 75 }) - .toBuffer(); - - const afterSize = screenshot.length; - trace(`Screenshot resized from ${beforeSize} bytes to ${afterSize} bytes`); - - mimeType = "image/jpeg"; - } - - const screenshot64 = screenshot.toString("base64"); - trace(`Screenshot taken: ${screenshot.length} bytes`); - posthog("tool_invoked", { - "ToolName": "mobile_take_screenshot", - "ScreenshotFilesize": screenshot64.length, - "ScreenshotMimeType": mimeType, - "ScreenshotWidth": pngSize.width, - "ScreenshotHeight": pngSize.height, - }).then(); - - return { - content: [{ type: "image", data: screenshot64, mimeType }] - }; - } catch (err: any) { - error(`Error taking screenshot: ${err.message} ${err.stack}`); - return { - content: [{ type: "text", text: `Error: ${err.message}` }], - isError: true, - }; - } - } - ); - - tool( - "mobile_set_orientation", - "Set Orientation", - "Change the screen orientation of the device", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - orientation: z.enum(["portrait", "landscape"]).describe("The desired orientation"), - }, - { destructiveHint: true }, - async ({ device, orientation }) => { - const robot = getRobotFromDevice(device); - await robot.setOrientation(orientation); - return `Changed device orientation to ${orientation}`; - } - ); - - tool( - "mobile_get_orientation", - "Get Orientation", - "Get the current screen orientation of the device", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.") - }, - { readOnlyHint: true }, - async ({ device }) => { - const robot = getRobotFromDevice(device); - const orientation = await robot.getOrientation(); - return `Current device orientation is ${orientation}`; - } - ); - - tool( - "mobile_start_screen_recording", - "Start Screen Recording", - "Start recording the screen of a mobile device. The recording runs in the background until stopped with mobile_stop_screen_recording. Returns the path where the recording will be saved.", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - output: z.string().optional().describe("The file path to save the recording to. Filename must end with .mp4. If not provided, a temporary path will be used."), - timeLimit: z.coerce.number().optional().describe("Maximum recording duration in seconds. The recording will stop automatically after this time."), - }, - { destructiveHint: true }, - async ({ device, output, timeLimit }) => { - if (output) { - validateFileExtension(output, ALLOWED_RECORDING_EXTENSIONS, "start_screen_recording"); - validateOutputPath(output); - } - - getRobotFromDevice(device); - - if (activeRecordings.has(device)) { - throw new ActionableError(`Device "${device}" is already being recorded. Stop the current recording first with mobile_stop_screen_recording.`); - } - - const outputPath = output || path.join(os.tmpdir(), `screen-recording-${Date.now()}.mp4`); - - const args = ["screenrecord", "--device", device, "--output", outputPath, "--silent"]; - if (timeLimit !== undefined) { - args.push("--time-limit", String(timeLimit)); - } - - const child = mobilecli.spawnCommand(args); - - const cleanup = () => { - activeRecordings.delete(device); - }; - - child.on("error", cleanup); - child.on("exit", cleanup); - - activeRecordings.set(device, { - process: child, - outputPath, - startedAt: Date.now(), - }); - - return `Screen recording started. Output will be saved to: ${outputPath}`; - } - ); - - tool( - "mobile_stop_screen_recording", - "Stop Screen Recording", - "Stop an active screen recording on a mobile device. Returns the file path, size, and approximate duration of the recording.", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - }, - { destructiveHint: true }, - async ({ device }) => { - const recording = activeRecordings.get(device); - if (!recording) { - throw new ActionableError(`No active recording found for device "${device}". Start a recording first with mobile_start_screen_recording.`); - } - - const { process: child, outputPath, startedAt } = recording; - activeRecordings.delete(device); - - child.kill("SIGINT"); - - await new Promise(resolve => { - const timeout = setTimeout(() => { - child.kill("SIGKILL"); - resolve(); - }, 5 * 60 * 1000); - - child.on("close", () => { - clearTimeout(timeout); - resolve(); - }); - }); - - const durationSeconds = Math.round((Date.now() - startedAt) / 1000); - - if (!fs.existsSync(outputPath)) { - return `Recording stopped after ~${durationSeconds}s but the output file was not found at: ${outputPath}`; - } - - const stats = fs.statSync(outputPath); - const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2); - - return `Recording stopped. File: ${outputPath} (${fileSizeMB} MB, ~${durationSeconds}s)`; - } - ); - - tool( - "mobile_list_crashes", - "List Crash Reports", - "List crash reports available on the device", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - }, - { readOnlyHint: true }, - async ({ device }) => { - ensureMobilecliAvailable(); - const response = mobilecli.crashesList(device); - return JSON.stringify(response.data); - } - ); - - tool( - "mobile_get_crash", - "Get Crash Report", - "Get the full content of a crash report by its ID. Use mobile_list_crashes to find available crash IDs.", - { - device: z.string().describe("The device identifier to use. Use mobile_list_available_devices to find which devices are available to you."), - id: z.string().describe("The crash report ID to retrieve"), - }, - { readOnlyHint: true }, - async ({ device, id }) => { - ensureMobilecliAvailable(); - const response = mobilecli.crashesGet(device, id); - return response.data.content; - } - ); - - return server; + const server = new McpServer({ + name: 'mobile-mcp', + version: getAgentVersion(), + }); + + const getClientName = (): string => { + try { + const clientInfo = server.server.getClientVersion(); + const clientName = clientInfo?.name || 'unknown'; + return clientName; + } catch (error: any) { + return 'unknown'; + } + }; + + type ZodSchemaShape = Record; + + interface ToolAnnotations { + readOnlyHint?: boolean; + destructiveHint?: boolean; + } + + const tool = ( + name: string, + title: string, + description: string, + paramsSchema: ZodSchemaShape, + annotations: ToolAnnotations, + cb: (args: any) => Promise, + ) => { + const finalDescription = isNormalized() + ? rewriteDescription(description) + : description; + server.registerTool( + name, + { + title, + description: finalDescription, + inputSchema: paramsSchema, + annotations, + }, + (async (args: any, _extra: any) => { + try { + trace(`Invoking ${name} with args: ${JSON.stringify(args)}`); + + // coord shim: denormalize input 0–scale → pixels + if (isNormalized() && args.device) { + const size = await ensureScreenSize(args.device); + if (size) { + denormalizeArgs(name, args, size.width, size.height); + } + } + + const start = +new Date(); + let response = await cb(args); + const duration = +new Date() - start; + + // coord shim: ingest screen size from get_screen_size before normalizing + if (name === 'mobile_get_screen_size' && args.device) { + ingestScreenSizeFromResult(args.device, response); + } + + // coord shim: normalize output pixels → 0–scale + if (isNormalized() && args.device) { + const size = getCachedScreenSize(args.device); + if (size) { + response = normalizeElementResult( + name, + response, + size.width, + size.height, + ); + } + response = normalizeScreenSizeResult(name, response); + } + + trace(`=> ${response}`); + posthog('tool_invoked', { + ToolName: name, + Duration: duration, + }).then(); + return { + content: [{ type: 'text', text: response }], + }; + } catch (error: any) { + posthog('tool_failed', { ToolName: name }).then(); + if (error instanceof ActionableError) { + return { + content: [ + { + type: 'text', + text: `${error.message}. Please fix the issue and try again.`, + }, + ], + }; + } else { + // a real exception + trace( + `Tool '${description}' failed: ${error.message} stack: ${error.stack}`, + ); + return { + content: [{ type: 'text', text: `Error: ${error.message}` }], + isError: true, + }; + } + } + }) as any, + ); + }; + + const posthog = async ( + event: string, + properties: Record, + ) => { + // Telemetry disabled in @qwen-code/mobile-mcp fork. + // Set MOBILEMCP_ENABLE_TELEMETRY=1 to re-enable upstream PostHog reporting. + if (!process.env.MOBILEMCP_ENABLE_TELEMETRY) { + return; + } + + try { + const url = 'https://us.i.posthog.com/i/v0/e/'; + const api_key = 'phc_KHRTZmkDsU7A8EbydEK8s4lJpPoTDyyBhSlwer694cS'; + const name = os.hostname() + process.execPath; + const distinct_id = crypto + .createHash('sha256') + .update(name) + .digest('hex'); + const systemProps: any = { + Platform: os.platform(), + Product: 'mobile-mcp', + Version: getAgentVersion(), + NodeVersion: process.version, + CI: process.env.CI || '0', + }; + + const clientName = getClientName(); + if (clientName !== 'unknown') { + systemProps.AgentName = clientName; + } + + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + api_key, + event, + properties: { + ...systemProps, + ...properties, + }, + distinct_id, + }), + }); + } catch (err: any) { + // ignore + } + }; + + const mobilecli = new Mobilecli(); + const activeRecordings = new Map(); + const agentVerifiedSimulators = new Set(); + posthog('launch', {}).then(); + + const ensureMobilecliAvailable = (): void => { + try { + const version = mobilecli.getVersion(); + if (version.startsWith('failed')) { + throw new Error('mobilecli version check failed'); + } + } catch (error: any) { + throw new ActionableError( + `mobilecli is not available or not working properly. Please review the documentation at https://github.com/mobile-next/mobile-mcp/wiki for installation instructions`, + ); + } + }; + + const getRobotFromDevice = (deviceId: string): Robot => { + // from now on, we must have mobilecli working + ensureMobilecliAvailable(); + + // Check if it's an iOS device + const iosManager = new IosManager(); + const iosDevices = iosManager.listDevices(); + const iosDevice = iosDevices.find((d) => d.deviceId === deviceId); + if (iosDevice) { + posthog('get_robot', { + DevicePlatform: 'ios', + DeviceType: 'real', + }).then(); + return new IosRobot(deviceId); + } + + // Check if it's an Android device + const androidManager = new AndroidDeviceManager(); + const androidDevices = androidManager.getConnectedDevices(); + const androidDevice = androidDevices.find((d) => d.deviceId === deviceId); + if (androidDevice) { + posthog('get_robot', { DevicePlatform: 'android' }).then(); + return new AndroidRobot(deviceId); + } + + // Check if it's a simulator (will later replace all other device types as well) + const response = mobilecli.getDevices({ + platform: 'ios', + type: 'simulator', + includeOffline: false, + }); + + if (response.status === 'ok' && response.data && response.data.devices) { + for (const device of response.data.devices) { + if (device.id === deviceId) { + if (!agentVerifiedSimulators.has(deviceId)) { + const agentStatus = mobilecli.agentStatus(deviceId); + if (agentStatus.status === 'fail') { + mobilecli.agentInstall(deviceId); + } + + agentVerifiedSimulators.add(deviceId); + } + + posthog('get_robot', { + DevicePlatform: 'ios', + DeviceType: 'simulator', + }).then(); + return new MobileDevice(deviceId); + } + } + } + + throw new ActionableError( + `Device "${deviceId}" not found. Use the mobile_list_available_devices tool to see available devices.`, + ); + }; + + const getAndroidRobotFromDevice = ( + deviceId: string, + toolName: string, + ): AndroidRobot => { + const androidManager = new AndroidDeviceManager(); + const androidDevices = androidManager.getConnectedDevices(); + const androidDevice = androidDevices.find((d) => d.deviceId === deviceId); + if (!androidDevice) { + throw new ActionableError( + `${toolName} is only supported on Android devices.`, + ); + } + return new AndroidRobot(deviceId); + }; + + async function ensureScreenSize( + deviceId: string, + ): Promise<{ width: number; height: number } | undefined> { + let size = getCachedScreenSize(deviceId); + if (size) return size; + try { + const robot = getRobotFromDevice(deviceId); + const screenSize = await robot.getScreenSize(); + cacheScreenSize(deviceId, screenSize.width, screenSize.height); + return { width: screenSize.width, height: screenSize.height }; + } catch { + return undefined; + } + } + + tool( + 'mobile_list_available_devices', + 'List Devices', + 'List all available devices. This includes both physical mobile devices and mobile simulators and emulators. It returns both Android and iOS devices.', + {}, + { readOnlyHint: true }, + async ({}) => { + // from today onward, we must have mobilecli working + ensureMobilecliAvailable(); + + const iosManager = new IosManager(); + const androidManager = new AndroidDeviceManager(); + const devices: MobilecliDevice[] = []; + + // Get Android devices with details + const androidDevices = androidManager.getConnectedDevicesWithDetails(); + for (const device of androidDevices) { + devices.push({ + id: device.deviceId, + name: device.name, + platform: 'android', + type: 'emulator', + version: device.version, + state: 'online', + }); + } + + // Get iOS physical devices with details + try { + const iosDevices = iosManager.listDevicesWithDetails(); + for (const device of iosDevices) { + devices.push({ + id: device.deviceId, + name: device.deviceName, + platform: 'ios', + type: 'real', + version: device.version, + state: 'online', + }); + } + } catch (error: any) { + // If go-ios is not available, silently skip + } + + // Get iOS simulators from mobilecli (excluding offline devices) + const response = mobilecli.getDevices({ + platform: 'ios', + type: 'simulator', + includeOffline: false, + }); + if (response.status === 'ok' && response.data && response.data.devices) { + for (const device of response.data.devices) { + devices.push({ + id: device.id, + name: device.name, + platform: device.platform, + type: device.type, + version: device.version, + state: 'online', + }); + } + } + + const out: MobilecliDevicesResponse = { devices }; + return JSON.stringify(out); + }, + ); + + if (process.env.MOBILEFLEET_ENABLE === '1') { + tool( + 'mobile_list_remote_devices', + 'List Remote Devices', + 'List devices available in the remote fleet', + {}, + { readOnlyHint: true }, + async ({}) => { + ensureMobilecliAvailable(); + const result = mobilecli.remoteListDevices(); + return result; + }, + ); + + tool( + 'mobile_allocate_remote_device', + 'Allocate Remote Device', + 'Reserve a device from the remote fleet', + { + platform: z + .enum(['ios', 'android']) + .describe('The platform to allocate a device for'), + }, + { destructiveHint: true }, + async ({ platform }) => { + ensureMobilecliAvailable(); + const result = mobilecli.remoteAllocate(platform); + return result; + }, + ); + + tool( + 'mobile_release_remote_device', + 'Release Remote Device', + 'Release a device back to the remote fleet', + { + device: z + .string() + .describe( + 'The device identifier to release back to the remote fleet', + ), + }, + { destructiveHint: true }, + async ({ device }) => { + ensureMobilecliAvailable(); + const result = mobilecli.remoteRelease(device); + return result; + }, + ); + } + + tool( + 'mobile_list_apps', + 'List Apps', + 'List all the installed apps on the device', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + }, + { readOnlyHint: true }, + async ({ device }) => { + const robot = getRobotFromDevice(device); + const result = await robot.listApps(); + return `Found these apps on device: ${result.map((app) => `${app.appName} (${app.packageName})`).join(', ')}`; + }, + ); + + tool( + 'mobile_launch_app', + 'Launch App', + 'Launch an app on mobile device. Use this to open a specific app. You can find the package name of the app by calling list_apps_on_device.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + packageName: z.string().describe('The package name of the app to launch'), + locale: z + .string() + .optional() + .describe( + 'Comma-separated BCP 47 locale tags to launch the app with (e.g., fr-FR,en-GB)', + ), + }, + { destructiveHint: true }, + async ({ device, packageName, locale }) => { + const robot = getRobotFromDevice(device); + await robot.launchApp(packageName, locale); + return `Launched app ${packageName}`; + }, + ); + + tool( + 'mobile_terminate_app', + 'Terminate App', + 'Stop and terminate an app on mobile device', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + packageName: z + .string() + .describe('The package name of the app to terminate'), + }, + { destructiveHint: true }, + async ({ device, packageName }) => { + const robot = getRobotFromDevice(device); + await robot.terminateApp(packageName); + return `Terminated app ${packageName}`; + }, + ); + + tool( + 'mobile_install_app', + 'Install App', + 'Install an app on mobile device', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + path: z + .string() + .describe( + 'The path to the app file to install. For iOS simulators, provide a .zip file or a .app directory. For Android provide an .apk file. For iOS real devices provide an .ipa file', + ), + grant_permissions: z + .boolean() + .optional() + .describe( + '(Android only) Grant all runtime permissions on install (-g flag). Defaults to false.', + ), + replace: z + .boolean() + .optional() + .describe( + '(Android only) Replace existing application (-r flag). Defaults to true.', + ), + allow_downgrade: z + .boolean() + .optional() + .describe( + '(Android only) Allow version code downgrade (-d flag). Defaults to false.', + ), + allow_test: z + .boolean() + .optional() + .describe( + '(Android only) Allow test APKs (-t flag). Defaults to false.', + ), + }, + { destructiveHint: true }, + async ({ + device, + path, + grant_permissions, + replace, + allow_downgrade, + allow_test, + }) => { + const robot = getRobotFromDevice(device); + const options: InstallOptions = { + grantPermissions: grant_permissions, + replace: replace, + allowDowngrade: allow_downgrade, + allowTest: allow_test, + }; + await robot.installApp(path, options); + return `Installed app from ${path}`; + }, + ); + + tool( + 'mobile_uninstall_app', + 'Uninstall App', + 'Uninstall an app from mobile device', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + bundle_id: z + .string() + .describe( + 'Bundle identifier (iOS) or package name (Android) of the app to be uninstalled', + ), + }, + { destructiveHint: true }, + async ({ device, bundle_id }) => { + const robot = getRobotFromDevice(device); + await robot.uninstallApp(bundle_id); + return `Uninstalled app ${bundle_id}`; + }, + ); + + tool( + 'mobile_get_screen_size', + 'Get Screen Size', + 'Get the screen size of the mobile device in pixels', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + }, + { readOnlyHint: true }, + async ({ device }) => { + const robot = getRobotFromDevice(device); + const screenSize = await robot.getScreenSize(); + return `Screen size is ${screenSize.width}x${screenSize.height} pixels`; + }, + ); + + tool( + 'mobile_click_on_screen_at_coordinates', + 'Click Screen', + 'Click on the screen at given x,y coordinates. If clicking on an element, use the list_elements_on_screen tool to find the coordinates.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + x: z.coerce + .number() + .describe( + coordParamDesc('The x coordinate to click on the screen, in pixels'), + ), + y: z.coerce + .number() + .describe( + coordParamDesc('The y coordinate to click on the screen, in pixels'), + ), + }, + { destructiveHint: true }, + async ({ device, x, y }) => { + const robot = getRobotFromDevice(device); + await robot.tap(x, y); + return `Clicked on screen at coordinates: ${x}, ${y}`; + }, + ); + + tool( + 'mobile_double_tap_on_screen', + 'Double Tap Screen', + 'Double-tap on the screen at given x,y coordinates.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + x: z.coerce + .number() + .describe(coordParamDesc('The x coordinate to double-tap, in pixels')), + y: z.coerce + .number() + .describe(coordParamDesc('The y coordinate to double-tap, in pixels')), + }, + { destructiveHint: true }, + async ({ device, x, y }) => { + const robot = getRobotFromDevice(device); + await robot!.doubleTap(x, y); + return `Double-tapped on screen at coordinates: ${x}, ${y}`; + }, + ); + + tool( + 'mobile_long_press_on_screen_at_coordinates', + 'Long Press Screen', + 'Long press on the screen at given x,y coordinates. If long pressing on an element, use the list_elements_on_screen tool to find the coordinates.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + x: z.coerce + .number() + .describe( + coordParamDesc( + 'The x coordinate to long press on the screen, in pixels', + ), + ), + y: z.coerce + .number() + .describe( + coordParamDesc( + 'The y coordinate to long press on the screen, in pixels', + ), + ), + duration: z.coerce + .number() + .min(1) + .max(10000) + .optional() + .describe( + 'Duration of the long press in milliseconds. Defaults to 500ms.', + ), + }, + { destructiveHint: true }, + async ({ device, x, y, duration }) => { + const robot = getRobotFromDevice(device); + const pressDuration = duration ?? 500; + await robot.longPress(x, y, pressDuration); + return `Long pressed on screen at coordinates: ${x}, ${y} for ${pressDuration}ms`; + }, + ); + + tool( + 'mobile_list_elements_on_screen', + 'List Screen Elements', + 'List elements on screen and their coordinates, with display text or accessibility label. Do not cache this result.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + }, + { readOnlyHint: true }, + async ({ device }) => { + const robot = getRobotFromDevice(device); + const elements = await robot.getElementsOnScreen(); + + const result = elements.map((element) => { + const out: any = { + type: element.type, + text: element.text, + label: element.label, + name: element.name, + value: element.value, + identifier: element.identifier, + coordinates: { + x: element.rect.x, + y: element.rect.y, + width: element.rect.width, + height: element.rect.height, + }, + }; + + if (element.focused) { + out.focused = true; + } + + return out; + }); + + return `Found these elements on screen: ${JSON.stringify(result)}`; + }, + ); + + tool( + 'mobile_press_button', + 'Press Button', + 'Press a button on device', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + button: z + .string() + .describe( + 'The button to press. Supported buttons: BACK (android only), HOME, VOLUME_UP, VOLUME_DOWN, ENTER, DPAD_CENTER (android tv only), DPAD_UP (android tv only), DPAD_DOWN (android tv only), DPAD_LEFT (android tv only), DPAD_RIGHT (android tv only)', + ), + }, + { destructiveHint: true }, + async ({ device, button }) => { + const robot = getRobotFromDevice(device); + await robot.pressButton(button); + return `Pressed the button: ${button}`; + }, + ); + + tool( + 'mobile_open_url', + 'Open URL', + 'Open a URL in browser on device', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + url: z.string().describe('The URL to open'), + }, + { destructiveHint: true }, + async ({ device, url }) => { + const allowUnsafeUrls = process.env.MOBILEMCP_ALLOW_UNSAFE_URLS === '1'; + if ( + !allowUnsafeUrls && + !url.startsWith('http://') && + !url.startsWith('https://') + ) { + throw new ActionableError( + 'Only http:// and https:// URLs are allowed. Set MOBILEMCP_ALLOW_UNSAFE_URLS=1 to allow other URL schemes.', + ); + } + + const robot = getRobotFromDevice(device); + await robot.openUrl(url); + return `Opened URL: ${url}`; + }, + ); + + tool( + 'mobile_swipe_on_screen', + 'Swipe Screen', + 'Swipe on the screen', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + direction: z + .enum(['up', 'down', 'left', 'right']) + .describe('The direction to swipe'), + x: z.coerce + .number() + .optional() + .describe( + coordParamDesc( + 'The x coordinate to start the swipe from, in pixels. If not provided, uses center of screen', + ), + ), + y: z.coerce + .number() + .optional() + .describe( + coordParamDesc( + 'The y coordinate to start the swipe from, in pixels. If not provided, uses center of screen', + ), + ), + distance: z.coerce + .number() + .optional() + .describe( + coordParamDesc( + 'The distance to swipe in pixels. Defaults to 400 pixels for iOS or 30% of screen dimension for Android', + ), + ), + }, + { destructiveHint: true }, + async ({ device, direction, x, y, distance }) => { + const robot = getRobotFromDevice(device); + + if (x !== undefined && y !== undefined) { + // Use coordinate-based swipe + await robot.swipeFromCoordinate(x, y, direction, distance); + const distanceText = distance ? ` ${distance} pixels` : ''; + return `Swiped ${direction}${distanceText} from coordinates: ${x}, ${y}`; + } else { + // Use center-based swipe + await robot.swipe(direction); + return `Swiped ${direction} on screen`; + } + }, + ); + + tool( + 'mobile_type_keys', + 'Type Text', + 'Type text into the focused element', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + text: z.string().describe('The text to type'), + submit: z + .boolean() + .describe( + 'Whether to submit the text. If true, the text will be submitted as if the user pressed the enter key.', + ), + }, + { destructiveHint: true }, + async ({ device, text, submit }) => { + const robot = getRobotFromDevice(device); + await robot.sendKeys(text); + + if (submit) { + await robot.pressButton('ENTER'); + } + + return `Typed text: ${text}`; + }, + ); + + tool( + 'mobile_save_screenshot', + 'Save Screenshot', + 'Save a screenshot of the mobile device to a file', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + saveTo: z + .string() + .describe( + 'The path to save the screenshot to. Filename must end with .png, .jpg, or .jpeg', + ), + }, + { destructiveHint: true }, + async ({ device, saveTo }) => { + validateFileExtension( + saveTo, + ALLOWED_SCREENSHOT_EXTENSIONS, + 'save_screenshot', + ); + validateOutputPath(saveTo); + + const robot = getRobotFromDevice(device); + + const screenshot = await robot.getScreenshot(); + fs.writeFileSync(saveTo, screenshot); + return `Screenshot saved to: ${saveTo}`; + }, + ); + + server.registerTool( + 'mobile_take_screenshot', + { + title: 'Take Screenshot', + description: + "Take a screenshot of the mobile device. Use this to understand what's on screen, if you need to press an element that is available through view hierarchy then you must list elements on screen instead. Do not cache this result.", + inputSchema: { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + }, + annotations: { + readOnlyHint: true, + }, + }, + async ({ device }) => { + try { + const robot = getRobotFromDevice(device); + const screenSize = await robot.getScreenSize(); + + let screenshot = await robot.getScreenshot(); + let mimeType = 'image/png'; + + // validate we received a png, will throw exception otherwise + const image = new PNG(screenshot); + const pngSize = image.getDimensions(); + if (pngSize.width <= 0 || pngSize.height <= 0) { + throw new ActionableError('Screenshot is invalid. Please try again.'); + } + + if (isScalingAvailable()) { + trace('Image scaling is available, resizing screenshot'); + const image = Image.fromBuffer(screenshot); + const beforeSize = screenshot.length; + screenshot = image + .resize(Math.floor(pngSize.width / screenSize.scale)) + .jpeg({ quality: 75 }) + .toBuffer(); + + const afterSize = screenshot.length; + trace( + `Screenshot resized from ${beforeSize} bytes to ${afterSize} bytes`, + ); + + mimeType = 'image/jpeg'; + } + + const screenshot64 = screenshot.toString('base64'); + trace(`Screenshot taken: ${screenshot.length} bytes`); + posthog('tool_invoked', { + ToolName: 'mobile_take_screenshot', + ScreenshotFilesize: screenshot64.length, + ScreenshotMimeType: mimeType, + ScreenshotWidth: pngSize.width, + ScreenshotHeight: pngSize.height, + }).then(); + + const content: any[] = [ + { type: 'image', data: screenshot64, mimeType }, + ]; + if (isNormalized()) { + const scale = coordinateScale(); + content.push({ + type: 'text', + text: `Screenshot dimensions: ${scale}x${scale} (normalized coordinate space)`, + }); + } + return { content }; + } catch (err: any) { + error(`Error taking screenshot: ${err.message} ${err.stack}`); + return { + content: [{ type: 'text', text: `Error: ${err.message}` }], + isError: true, + }; + } + }, + ); + + tool( + 'mobile_set_orientation', + 'Set Orientation', + 'Change the screen orientation of the device', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + orientation: z + .enum(['portrait', 'landscape']) + .describe('The desired orientation'), + }, + { destructiveHint: true }, + async ({ device, orientation }) => { + const robot = getRobotFromDevice(device); + await robot.setOrientation(orientation); + return `Changed device orientation to ${orientation}`; + }, + ); + + tool( + 'mobile_get_orientation', + 'Get Orientation', + 'Get the current screen orientation of the device', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + }, + { readOnlyHint: true }, + async ({ device }) => { + const robot = getRobotFromDevice(device); + const orientation = await robot.getOrientation(); + return `Current device orientation is ${orientation}`; + }, + ); + + tool( + 'mobile_start_screen_recording', + 'Start Screen Recording', + 'Start recording the screen of a mobile device. The recording runs in the background until stopped with mobile_stop_screen_recording. Returns the path where the recording will be saved.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + output: z + .string() + .optional() + .describe( + 'The file path to save the recording to. Filename must end with .mp4. If not provided, a temporary path will be used.', + ), + timeLimit: z.coerce + .number() + .optional() + .describe( + 'Maximum recording duration in seconds. The recording will stop automatically after this time.', + ), + }, + { destructiveHint: true }, + async ({ device, output, timeLimit }) => { + if (output) { + validateFileExtension( + output, + ALLOWED_RECORDING_EXTENSIONS, + 'start_screen_recording', + ); + validateOutputPath(output); + } + + getRobotFromDevice(device); + + if (activeRecordings.has(device)) { + throw new ActionableError( + `Device "${device}" is already being recorded. Stop the current recording first with mobile_stop_screen_recording.`, + ); + } + + const outputPath = + output || path.join(os.tmpdir(), `screen-recording-${Date.now()}.mp4`); + + const args = [ + 'screenrecord', + '--device', + device, + '--output', + outputPath, + '--silent', + ]; + if (timeLimit !== undefined) { + args.push('--time-limit', String(timeLimit)); + } + + const child = mobilecli.spawnCommand(args); + + const cleanup = () => { + activeRecordings.delete(device); + }; + + child.on('error', cleanup); + child.on('exit', cleanup); + + activeRecordings.set(device, { + process: child, + outputPath, + startedAt: Date.now(), + }); + + return `Screen recording started. Output will be saved to: ${outputPath}`; + }, + ); + + tool( + 'mobile_stop_screen_recording', + 'Stop Screen Recording', + 'Stop an active screen recording on a mobile device. Returns the file path, size, and approximate duration of the recording.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + }, + { destructiveHint: true }, + async ({ device }) => { + const recording = activeRecordings.get(device); + if (!recording) { + throw new ActionableError( + `No active recording found for device "${device}". Start a recording first with mobile_start_screen_recording.`, + ); + } + + const { process: child, outputPath, startedAt } = recording; + activeRecordings.delete(device); + + child.kill('SIGINT'); + + await new Promise((resolve) => { + const timeout = setTimeout( + () => { + child.kill('SIGKILL'); + resolve(); + }, + 5 * 60 * 1000, + ); + + child.on('close', () => { + clearTimeout(timeout); + resolve(); + }); + }); + + const durationSeconds = Math.round((Date.now() - startedAt) / 1000); + + if (!fs.existsSync(outputPath)) { + return `Recording stopped after ~${durationSeconds}s but the output file was not found at: ${outputPath}`; + } + + const stats = fs.statSync(outputPath); + const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2); + + return `Recording stopped. File: ${outputPath} (${fileSizeMB} MB, ~${durationSeconds}s)`; + }, + ); + + tool( + 'mobile_list_crashes', + 'List Crash Reports', + 'List crash reports available on the device', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + }, + { readOnlyHint: true }, + async ({ device }) => { + ensureMobilecliAvailable(); + const response = mobilecli.crashesList(device); + return JSON.stringify(response.data); + }, + ); + + tool( + 'mobile_get_crash', + 'Get Crash Report', + 'Get the full content of a crash report by its ID. Use mobile_list_crashes to find available crash IDs.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + id: z.string().describe('The crash report ID to retrieve'), + }, + { readOnlyHint: true }, + async ({ device, id }) => { + ensureMobilecliAvailable(); + const response = mobilecli.crashesGet(device, id); + return response.data.content; + }, + ); + + // --- Android-specific ADB tools --- + + tool( + 'mobile_ui_dump', + 'UI Hierarchy Dump', + '(Android only) Dump the full UI hierarchy as raw XML using uiautomator. Unlike mobile_list_elements_on_screen which returns a filtered flat list of interactive elements as JSON, this returns the complete unfiltered XML tree preserving parent-child hierarchy and all node attributes (class, resource-id, bounds, clickable, scrollable, enabled, etc.). Use when you need the full view tree for debugging, or when mobile_list_elements_on_screen misses an element you can see on screen. Supports --compressed to reduce output size.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + compressed: z + .boolean() + .optional() + .describe( + 'Whether to use --compressed flag to reduce XML output size. Defaults to false.', + ), + output_path: z + .string() + .optional() + .describe( + 'If provided, save the XML to this local file path instead of returning it in the response.', + ), + }, + { readOnlyHint: true }, + async ({ device, compressed, output_path }) => { + const robot = getAndroidRobotFromDevice(device, 'mobile_ui_dump'); + const xml = await robot.dumpUiHierarchy(compressed ?? false); + if (output_path) { + validateOutputPath(output_path); + fs.writeFileSync(output_path, xml, 'utf-8'); + return `UI hierarchy XML saved to: ${output_path}`; + } + return xml; + }, + ); + + tool( + 'mobile_adb_pull', + 'ADB Pull File', + '(Android only) Pull (download) a file from the Android device to the local filesystem.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + remote_path: z + .string() + .describe('The file path on the Android device to pull from'), + local_path: z + .string() + .describe('The local file path to save the pulled file to.'), + }, + { readOnlyHint: true }, + async ({ device, remote_path, local_path }) => { + const robot = getAndroidRobotFromDevice(device, 'mobile_adb_pull'); + validateOutputPath(local_path); + robot.pullFile(remote_path, local_path); + return `Successfully pulled ${remote_path} to ${local_path}`; + }, + ); + + tool( + 'mobile_adb_push', + 'ADB Push File', + '(Android only) Push (upload) a file from local filesystem to the Android device. By default only allows pushing to /sdcard/. Set force=true to push to other paths.', + { + device: z + .string() + .describe( + 'The device identifier to use. Use mobile_list_available_devices to find which devices are available to you.', + ), + local_path: z + .string() + .describe('The local file path to push to the device'), + remote_path: z + .string() + .describe('The target file path on the Android device'), + force: z + .boolean() + .optional() + .describe( + 'Set to true to allow pushing to paths outside /sdcard/. Defaults to false.', + ), + }, + { destructiveHint: true }, + async ({ device, local_path, remote_path, force }) => { + const robot = getAndroidRobotFromDevice(device, 'mobile_adb_push'); + if (!force && !remote_path.startsWith('/sdcard/')) { + throw new ActionableError( + `Push target path must start with /sdcard/ for safety. Got: "${remote_path}". Set force=true to override this restriction.`, + ); + } + if (!fs.existsSync(local_path)) { + throw new ActionableError(`Local file not found: "${local_path}"`); + } + validateOutputPath(local_path); + robot.pushFile(local_path, remote_path); + return `Successfully pushed ${local_path} to ${remote_path}`; + }, + ); + + return server; }; diff --git a/packages/mobile-mcp/test/coord-norm.test.ts b/packages/mobile-mcp/test/coord-norm.test.ts new file mode 100644 index 00000000000..6a030d15fd2 --- /dev/null +++ b/packages/mobile-mcp/test/coord-norm.test.ts @@ -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 = {}) { + const origEnv: Record = {}; + 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(); + } +}); From c61aae0061dda66d976fa6395fd3a0c6e62a35f8 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 3 Jul 2026 14:38:18 +0800 Subject: [PATCH 3/7] chore(mobile-mcp): update package-lock.json for workspace dependencies --- package-lock.json | 1261 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 1220 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index fd328894a14..d961e8500b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1047,6 +1047,16 @@ "node": ">=18" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.6", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", @@ -1812,34 +1822,499 @@ "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=18.18" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "license": "ISC" + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@iarna/toml": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", - "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", - "license": "ISC" - }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "license": "MIT" - }, - "node_modules/@iconify/utils": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", - "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", - "license": "MIT", - "dependencies": { - "@antfu/install-pkg": "^1.1.0", - "@iconify/types": "^2.0.0", - "import-meta-resolve": "^4.2.0" + "url": "https://opencollective.com/libvips" } }, "node_modules/@inquirer/confirm": { @@ -2565,6 +3040,59 @@ "@chevrotain/types": "~11.1.1" } }, + "node_modules/@mobilewright/core": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@mobilewright/core/-/core-0.0.45.tgz", + "integrity": "sha512-5wtqoG4AOAkr3u+Gegodvt6aeuF2Qe6Y5P0/p1HL8S1lFZ035MCD96TxE0zEDYNVaYyVLIaIAN59ri2O3Vi+JQ==", + "license": "Apache-2.0", + "dependencies": { + "@mobilewright/protocol": "^0.0.45", + "debug": "^4.4.3", + "playwright-core": "1.58.2", + "sharp": "^0.34.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mobilewright/driver-mobilecli": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@mobilewright/driver-mobilecli/-/driver-mobilecli-0.0.45.tgz", + "integrity": "sha512-6v8hyjw3jgP6TeH4yqtKfXphr2sSIZj0BhD013Juk1//AYOdnGdltC2c0kbdeWUdqFB+K3G4Kc32fBVi7Gbfgg==", + "license": "Apache-2.0", + "dependencies": { + "@mobilewright/protocol": "^0.0.45", + "debug": "^4.4.3", + "mobilecli": "0.3.84", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mobilewright/driver-mobilenext": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@mobilewright/driver-mobilenext/-/driver-mobilenext-0.0.45.tgz", + "integrity": "sha512-xXGuAb+ihezvtETr883mIlxreqECjRdXSL8jbFgK4VhJSk1/P+98zWlTMQRMVgwKOg9IKv+GG6JXSkTFD08YKw==", + "license": "Apache-2.0", + "dependencies": { + "@mobilewright/protocol": "^0.0.45", + "debug": "^4.4.3", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mobilewright/protocol": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@mobilewright/protocol/-/protocol-0.0.45.tgz", + "integrity": "sha512-bArKuq4f7woi9ysBiRb1UWxKGHgZ+vwEH8WbpMYXXoEelDTpes0MZfMs4YNWisYSdwl4IRLiHzseQqMkJDPePQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -2665,6 +3193,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3305,6 +3845,69 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/@playwright/test/node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@playwright/test/node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -3450,6 +4053,10 @@ "resolved": "packages/chrome-extension", "link": true }, + "node_modules/@qwen-code/mobile-mcp": { + "resolved": "packages/mobile-mcp", + "link": true + }, "node_modules/@qwen-code/qwen-code": { "resolved": "packages/cli", "link": true @@ -4341,6 +4948,39 @@ "node": ">=6" } }, + "node_modules/@stylistic/eslint-plugin": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz", + "integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.13.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", @@ -4796,6 +5436,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/commander": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@types/commander/-/commander-2.12.0.tgz", + "integrity": "sha512-DDmRkovH7jPjnx7HcbSnqKg2JeNANyxNZeUvB0iE+qKBLN+vzN5iSIwt+J2PFSmBuYEut4mgQvI/fTX9YQH/vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "*" + } + }, "node_modules/@types/configstore": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/configstore/-/configstore-6.0.2.tgz", @@ -5237,6 +5887,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6839,6 +7496,18 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -7701,6 +8370,40 @@ "node": ">= 0.8" } }, + "node_modules/c8": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^7.0.1", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -9601,9 +10304,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -10416,6 +11117,19 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin/-/eslint-plugin-1.0.1.tgz", + "integrity": "sha512-ervp8C09On0fLA258TvE08AqAr/bhRYgHVZd3BrJjD4JfOA2JGANDLGs06j51oWqfPd7Feoo3OoqHD+fuI2sFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "requireindex": "~1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eslint-plugin-check-file": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/eslint-plugin-check-file/-/eslint-plugin-check-file-3.3.1.tgz", @@ -10510,6 +11224,21 @@ "requireindex": "^1.2.0" } }, + "node_modules/eslint-plugin-notice": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-notice/-/eslint-plugin-notice-1.0.0.tgz", + "integrity": "sha512-M3VLQMZzZpvfTZ/vy9FmClIKq5rLBbQpM0KgfLZPJPrVXpmJYeobmmb+lfJzHWdNm8PWwvw8KlafQWo2N9xx1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-root": "^1.1.0", + "lodash": "^4.17.21", + "metric-lcs": "^0.1.2" + }, + "peerDependencies": { + "eslint": ">=3.0.0" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -10765,6 +11494,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/eslint-plugin/node_modules/requireindex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz", + "integrity": "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.5" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -11240,6 +11979,44 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.1.tgz", + "integrity": "sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -11339,6 +12116,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "dev": true, + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -15455,6 +16239,13 @@ "node": ">= 0.6" } }, + "node_modules/metric-lcs": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz", + "integrity": "sha512-+TZ5dUDPKPJaU/rscTzxyN8ZkX7eAVLAiQU/e+YINleXPv03SCmJShaMT1If1liTH8OcmWXZs0CmzCBRBLcMpA==", + "dev": true, + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -16333,8 +17124,48 @@ "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", "license": "MIT", - "dependencies": { - "obliterator": "^2.0.4" + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "node_modules/mobilecli": { + "version": "0.3.84", + "resolved": "https://registry.npmjs.org/mobilecli/-/mobilecli-0.3.84.tgz", + "integrity": "sha512-TOyyavbO5mtzMlJ6vdAZbtjgH4GM392biOxiKDV9s4lMPzA0TSUphBZVCOKsCN03kotNJA2JytdY500H6iHvJw==", + "license": "MIT", + "bin": { + "mobilecli": "index.js" + } + }, + "node_modules/mobilewright": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/mobilewright/-/mobilewright-0.0.45.tgz", + "integrity": "sha512-z9QJRtiGBax310zAhoMBvULm8uKnVPcVn+ELhII1E1LLPJDzoGYhFcVLfAzS8ela99KqYRKFS0D75ayCpENhFQ==", + "license": "Apache-2.0", + "dependencies": { + "@mobilewright/core": "^0.0.45", + "@mobilewright/driver-mobilecli": "^0.0.45", + "@mobilewright/driver-mobilenext": "^0.0.45", + "@mobilewright/protocol": "^0.0.45", + "commander": "^14.0.3", + "debug": "^4.4.3", + "playwright": "1.58.2", + "ws": "^8.18.0" + }, + "bin": { + "mobilewright": "dist/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mobilewright/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" } }, "node_modules/mock-fs": { @@ -17682,6 +18513,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", + "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -17852,13 +18698,12 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", - "dev": true, + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -17871,10 +18716,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "dev": true, + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -17887,14 +18731,12 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -19733,6 +20575,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -20526,6 +21412,21 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/structured-source": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", @@ -22115,6 +23016,21 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -22825,6 +23741,21 @@ "node": ">=18" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -23630,6 +24561,254 @@ "node": ">=18.17" } }, + "packages/mobile-mcp": { + "name": "@qwen-code/mobile-mcp", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "1.26.0", + "ajv": "^8.18.0", + "commander": "14.0.0", + "express": "5.1.0", + "fast-xml-parser": "5.8.0", + "mobilewright": "0.0.45", + "qs": "^6.15.0", + "zod": "^4.1.13", + "zod-to-json-schema": "3.25.0" + }, + "bin": { + "mcp-server-mobile": "lib/index.js" + }, + "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" + }, + "engines": { + "node": ">=18" + } + }, + "packages/mobile-mcp/node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "packages/mobile-mcp/node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/mobile-mcp/node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "packages/mobile-mcp/node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/mobile-mcp/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/mobile-mcp/node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "packages/mobile-mcp/node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/mobile-mcp/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "packages/mobile-mcp/node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "packages/mobile-mcp/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/mobile-mcp/node_modules/zod-to-json-schema": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", + "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", "version": "0.1.8", From fda5c03a42e667720ff12b6fa89aedd44d183886 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 3 Jul 2026 14:55:09 +0800 Subject: [PATCH 4/7] fix(mobile-mcp): quote all YAML strings to pass yamllint --- .github/workflows/cd-mobile-mcp.yml | 54 ++++++++++++++--------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/cd-mobile-mcp.yml b/.github/workflows/cd-mobile-mcp.yml index ad1a4062391..cdf11efc035 100644 --- a/.github/workflows/cd-mobile-mcp.yml +++ b/.github/workflows/cd-mobile-mcp.yml @@ -7,29 +7,29 @@ on: inputs: version: description: 'Version to publish (without v prefix, e.g. 0.1.0)' - required: true + required: 'true' dry_run: description: 'Dry run (build only, no publish)' - type: boolean - default: true + type: 'boolean' + default: 'true' permissions: - contents: read - id-token: write + contents: 'read' + id-token: 'write' jobs: build-and-publish: - runs-on: ubuntu-latest + runs-on: 'ubuntu-latest' steps: - - uses: actions/checkout@v4 + - uses: 'actions/checkout@v4' - - uses: actions/setup-node@v5 + - uses: 'actions/setup-node@v5' with: node-version: '22' registry-url: 'https://registry.npmjs.org' - - name: Determine version - id: version + - name: 'Determine version' + id: 'version' run: | if [[ "$GITHUB_REF" == refs/tags/mobile-mcp-v* ]]; then VERSION="${GITHUB_REF#refs/tags/mobile-mcp-v}" @@ -38,25 +38,25 @@ jobs: fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Install dependencies - working-directory: packages/mobile-mcp - run: npm ci --ignore-scripts + - 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: '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: '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: 'Build' + working-directory: 'packages/mobile-mcp' + run: 'npm run build' - - name: Publish - if: ${{ !inputs.dry_run }} - working-directory: packages/mobile-mcp - run: npm publish --access public --provenance + - name: 'Publish' + if: '${{ !inputs.dry_run }}' + working-directory: 'packages/mobile-mcp' + run: 'npm publish --access public --provenance' env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' From 149175914901d94faadea2ad1a6124e20f1c020f Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 3 Jul 2026 15:08:13 +0800 Subject: [PATCH 5/7] fix(mobile-mcp): fix cd workflow yaml to pass both yamllint and actionlint --- .github/workflows/cd-mobile-mcp.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd-mobile-mcp.yml b/.github/workflows/cd-mobile-mcp.yml index cdf11efc035..6facb658e63 100644 --- a/.github/workflows/cd-mobile-mcp.yml +++ b/.github/workflows/cd-mobile-mcp.yml @@ -7,11 +7,12 @@ on: inputs: version: description: 'Version to publish (without v prefix, e.g. 0.1.0)' - required: 'true' + required: true dry_run: description: 'Dry run (build only, no publish)' + required: false type: 'boolean' - default: 'true' + default: true permissions: contents: 'read' From 1dcd780105b55d6f6f1668dc7aadfe98eac42439 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 3 Jul 2026 15:48:26 +0800 Subject: [PATCH 6/7] fix(mobile-mcp): address review findings on our additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .github/workflows/cd-mobile-mcp.yml | 4 ++++ packages/mobile-mcp/src/coord-norm.ts | 14 ++++++++++---- packages/mobile-mcp/src/server.ts | 21 ++++++++++++++------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cd-mobile-mcp.yml b/.github/workflows/cd-mobile-mcp.yml index 6facb658e63..00869ebcf97 100644 --- a/.github/workflows/cd-mobile-mcp.yml +++ b/.github/workflows/cd-mobile-mcp.yml @@ -55,6 +55,10 @@ jobs: 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' diff --git a/packages/mobile-mcp/src/coord-norm.ts b/packages/mobile-mcp/src/coord-norm.ts index 7f9abb6e650..c34c1219b74 100644 --- a/packages/mobile-mcp/src/coord-norm.ts +++ b/packages/mobile-mcp/src/coord-norm.ts @@ -69,6 +69,10 @@ export function cacheScreenSize( screenSizeCache.set(deviceId, { width, height }); } +export function invalidateScreenSize(deviceId: string): void { + screenSizeCache.delete(deviceId); +} + export function getCachedScreenSize( deviceId: string, ): { width: number; height: number } | undefined { @@ -138,7 +142,8 @@ export function normalizeElementResult( } } return prefix + JSON.stringify(elements); - } catch { + } catch (err) { + console.error('[coord-norm] Failed to normalize element coordinates:', err); return response; } } @@ -169,9 +174,10 @@ export function ingestScreenSizeFromResult( export function rewriteDescription(description: string): string { const scale = coordinateScale(); - return description - .replace(/in pixels/g, `in 0-${scale} normalized coordinates`) - .replace(/\bin pixels\b/g, `in 0-${scale} normalized coordinates`); + return description.replace( + /\bin pixels\b/g, + `in 0-${scale} normalized coordinates`, + ); } export function coordParamDesc(baseDesc: string): string { diff --git a/packages/mobile-mcp/src/server.ts b/packages/mobile-mcp/src/server.ts index 4ac66b91463..23d10ba5614 100644 --- a/packages/mobile-mcp/src/server.ts +++ b/packages/mobile-mcp/src/server.ts @@ -24,6 +24,7 @@ import { ingestScreenSizeFromResult, getCachedScreenSize, cacheScreenSize, + invalidateScreenSize, rewriteDescription, coordParamDesc, } from './coord-norm'; @@ -318,7 +319,10 @@ export const createMcpServer = (): McpServer => { const screenSize = await robot.getScreenSize(); cacheScreenSize(deviceId, screenSize.width, screenSize.height); return { width: screenSize.width, height: screenSize.height }; - } catch { + } catch (err: any) { + trace( + `[coord-norm] Failed to get screen size for ${deviceId}: ${err.message}. Coordinates will pass through without normalization.`, + ); return undefined; } } @@ -1023,6 +1027,7 @@ export const createMcpServer = (): McpServer => { async ({ device, orientation }) => { const robot = getRobotFromDevice(device); await robot.setOrientation(orientation); + invalidateScreenSize(device); return `Changed device orientation to ${orientation}`; }, ); @@ -1269,7 +1274,7 @@ export const createMcpServer = (): McpServer => { .string() .describe('The local file path to save the pulled file to.'), }, - { readOnlyHint: true }, + { destructiveHint: true }, async ({ device, remote_path, local_path }) => { const robot = getAndroidRobotFromDevice(device, 'mobile_adb_pull'); validateOutputPath(local_path); @@ -1304,15 +1309,17 @@ export const createMcpServer = (): McpServer => { { destructiveHint: true }, async ({ device, local_path, remote_path, force }) => { const robot = getAndroidRobotFromDevice(device, 'mobile_adb_push'); - if (!force && !remote_path.startsWith('/sdcard/')) { - throw new ActionableError( - `Push target path must start with /sdcard/ for safety. Got: "${remote_path}". Set force=true to override this restriction.`, - ); + if (!force) { + const resolved = path.posix.resolve('/', remote_path); + if (!resolved.startsWith('/sdcard/')) { + throw new ActionableError( + `Push target path must resolve under /sdcard/ for safety. Got: "${remote_path}" (resolved: "${resolved}"). Set force=true to override.`, + ); + } } if (!fs.existsSync(local_path)) { throw new ActionableError(`Local file not found: "${local_path}"`); } - validateOutputPath(local_path); robot.pushFile(local_path, remote_path); return `Successfully pushed ${local_path} to ${remote_path}`; }, From 0c3f1d7c3244984a97149ce6a5a24fd6beef7eb3 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 3 Jul 2026 16:21:22 +0800 Subject: [PATCH 7/7] fix(mobile-mcp): update server.json identity and fix package.json main entrypoint --- packages/mobile-mcp/package.json | 2 +- packages/mobile-mcp/server.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mobile-mcp/package.json b/packages/mobile-mcp/package.json index 2746110acaf..9c99b8fdfbd 100644 --- a/packages/mobile-mcp/package.json +++ b/packages/mobile-mcp/package.json @@ -53,7 +53,7 @@ "husky": "^9.1.7", "typescript": "5.8.2" }, - "main": "index.js", + "main": "lib/index.js", "bin": { "mcp-server-mobile": "lib/index.js" }, diff --git a/packages/mobile-mcp/server.json b/packages/mobile-mcp/server.json index feeeb5370c1..754808b26c2 100644 --- a/packages/mobile-mcp/server.json +++ b/packages/mobile-mcp/server.json @@ -1,9 +1,9 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "io.github.mobile-next/mobile-mcp", - "description": "MCP server for iOS and Android Mobile Development, Automation and Testing", + "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/mobile-next/mobile-mcp", + "url": "https://github.com/QwenLM/qwen-code", "source": "github" }, "version": "{{VERSION}}", @@ -11,7 +11,7 @@ { "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", - "identifier": "@mobilenext/mobile-mcp", + "identifier": "@qwen-code/mobile-mcp", "version": "{{VERSION}}", "transport": { "type": "stdio"