From 4da4745a6c1b09cd57ef245f731f891433019ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 30 Jan 2026 19:41:00 +0100 Subject: [PATCH] initial commit --- .gitignore | 23 + AGENT.md | 55 +++ AGENTS.md | 9 + CONTRIBUTING.md | 42 ++ LICENSE | 21 + README.md | 94 ++++ bin/agent-device.mjs | 14 + docs/ios-automation.md | 39 ++ docs/ios-runner-protocol.md | 52 ++ .../project.pbxproj | 461 +++++++++++++++++ .../contents.xcworkspacedata | 7 + .../UserInterfaceState.xcuserstate | Bin 0 -> 25588 bytes .../xcschemes/AgentDeviceRunner.xcscheme | 102 ++++ .../AgentDeviceRunnerApp.swift | 17 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Assets.xcassets/Contents.json | 6 + .../AgentDeviceRunner/ContentView.swift | 24 + .../RunnerTests.swift | 351 +++++++++++++ ios-runner/README.md | 11 + package.json | 54 ++ pnpm-lock.yaml | 466 ++++++++++++++++++ rslib.config.ts | 30 ++ src/bin.ts | 3 + src/cli.ts | 122 +++++ src/core/dispatch.ts | 225 +++++++++ src/daemon-client.ts | 165 +++++++ src/daemon.ts | 207 ++++++++ src/platforms/android/devices.ts | 59 +++ src/platforms/android/index.ts | 251 ++++++++++ src/platforms/ios/devices.ts | 65 +++ src/platforms/ios/index.ts | 322 ++++++++++++ src/platforms/ios/runner-client.ts | 443 +++++++++++++++++ src/utils/args.ts | 102 ++++ src/utils/device.ts | 84 ++++ src/utils/errors.ts | 35 ++ src/utils/exec.ts | 229 +++++++++ src/utils/interactive.ts | 4 + src/utils/interactors.ts | 72 +++ src/utils/output.ts | 14 + test/integration/android-settings.test.ts | 218 ++++++++ test/integration/ios-settings.test.ts | 241 +++++++++ tsconfig.json | 12 + tsconfig.lib.json | 9 + 44 files changed, 4806 insertions(+) create mode 100644 .gitignore create mode 100644 AGENT.md create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bin/agent-device.mjs create mode 100644 docs/ios-automation.md create mode 100644 docs/ios-runner-protocol.md create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/xcuserdata/thymikee.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.swift create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Contents.json create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunner/ContentView.swift create mode 100644 ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift create mode 100644 ios-runner/README.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 rslib.config.ts create mode 100644 src/bin.ts create mode 100644 src/cli.ts create mode 100644 src/core/dispatch.ts create mode 100644 src/daemon-client.ts create mode 100644 src/daemon.ts create mode 100644 src/platforms/android/devices.ts create mode 100644 src/platforms/android/index.ts create mode 100644 src/platforms/ios/devices.ts create mode 100644 src/platforms/ios/index.ts create mode 100644 src/platforms/ios/runner-client.ts create mode 100644 src/utils/args.ts create mode 100644 src/utils/device.ts create mode 100644 src/utils/errors.ts create mode 100644 src/utils/exec.ts create mode 100644 src/utils/interactive.ts create mode 100644 src/utils/interactors.ts create mode 100644 src/utils/output.ts create mode 100644 test/integration/android-settings.test.ts create mode 100644 test/integration/ios-settings.test.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.lib.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..1cfab60b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +node_modules/ +.pnpm-store/ +dist/ +.DS_Store +*.log +test/screenshots/*.png +DerivedData/ +*.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ +xcuserdata/ +*.xcuserstate +*.xccheckout +*.xcscmblueprint +*.xcuserdatad/ +*.xcuserdata/ +*.xcsettings +*.xcresult +*.ipa +*.dSYM +*.dSYM.zip +*.app +*.xctestrun +*.xcarchive diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 000000000..bf6e33085 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,55 @@ +# AGENT.md + +Repository notes for future work. + +## Overview +- CLI entry: `src/bin.ts` -> `src/cli.ts`. +- Daemon: `src/daemon.ts`, started on demand by `src/daemon-client.ts`. +- Core dispatcher: `src/core/dispatch.ts` routes commands to platform interactors. +- iOS runner (simulator-only, v1): `src/platforms/ios/runner-client.ts` drives a UI test runner app. +- iOS runner Xcode project: `ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj`. +- iOS runner UI test: `ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift`. +- Android: `src/platforms/android/*` with ADB utilities. + +## Architecture (runtime flow) +1) CLI parses args in `src/cli.ts`, then sends a request to the daemon. +2) Daemon (`src/daemon.ts`) resolves device, tracks session state, and calls `dispatchCommand`. +3) `dispatchCommand` selects platform interactor. +4) iOS simulator path: + - Prefer `simctl` input when available (`simctlSupportsInput`). + - Fallback to iOS runner via `runIosRunnerCommand` for tap/type/swipe/list. +5) iOS runner uses xcodebuild `test-without-building` with an injected `.xctestrun`, + starts an `NWListener` HTTP server inside the UI test bundle, and executes UI actions. + +## Key commands (local) +- Build iOS runner: `xcodebuild build-for-testing -project ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -destination "platform=iOS Simulator,id=" -derivedDataPath ~/.agent-device/ios-runner/derived` +- Run command: `node bin/agent-device.mjs --platform ios --udid open settings --verbose` +- Scroll: `node bin/agent-device.mjs --platform ios --udid scroll down 0.5 --verbose` + +## iOS runner details +- Test method name used by CLI: `RunnerTests.testCommand`. +- The UI test launches the target app and listens for HTTP on a dynamic port. +- Port injection uses `.xctestrun` env vars and `AGENT_DEVICE_RUNNER_PORT`. +- Logs of readiness: `AGENT_DEVICE_RUNNER_LISTENER_READY` and `AGENT_DEVICE_RUNNER_PORT=...`. +- Main-thread UI actions are mandatory (XCUIApplication). + +## Daemon details +- Daemon info: `~/.agent-device/daemon.json` (port/token/pid/version). +- Daemon log: `~/.agent-device/daemon.log` (also tailed in verbose mode). + +## Environment variables +- `AGENT_DEVICE_RUNNER_PORT`: port passed into the UI test bundle. +- `AGENT_DEVICE_IOS_CLEAN_DERIVED=1`: delete `~/.agent-device/ios-runner/derived` before building/selecting xctestrun. +- `AGENT_DEVICE_DAEMON_TIMEOUT_MS`: timeout for daemon request response (min 1000ms, default 5000). + +## Common failure modes +- "Runner did not accept connection": + - Stale `.xctestrun` was selected; clean derived or rebuild. + - Listener not ready yet; check daemon log for readiness markers. +- XCTest main-thread crash: ensure UI actions run on main thread. + +## Files to check when debugging +- `src/platforms/ios/runner-client.ts` (xcodebuild, xctestrun injection, HTTP connect logic) +- `ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift` (listener + UI actions) +- `src/daemon-client.ts` and `src/daemon.ts` (daemon startup and request flow) +- `src/core/dispatch.ts` (command routing and iOS simctl fallback) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..7cea09ed2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# AGENTS.md + +Instructions for AI coding agents working with this codebase. + +## Code Style + +- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable. +- Use `runCmd`/`runCmdSync` from `src/utils/exec.ts` for process execution; avoid direct `spawn`/`spawnSync`. +- Commands should use the daemon session model; open a session before interactions and close it after. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..130e56a94 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing + +Thanks for your interest in contributing to agent-device. + +## Development + +Requirements: +- Node.js 22+ +- pnpm +- Android SDK tools (`adb`) for Android support +- Xcode (`simctl`/`devicectl`) for iOS support + +Setup: + +```bash +pnpm install +``` + +Run tests: + +```bash +pnpm test +``` + +Optional device selectors for tests: +- `ANDROID_DEVICE=Pixel_9_Pro_XL` or `ANDROID_SERIAL=emulator-5554` +- `IOS_DEVICE="iPhone 17 Pro"` or `IOS_UDID=` + +## Guidelines +- Keep dependencies minimal. +- Preserve the CLI’s agent-friendly JSON output. +- Ensure tests open and close sessions explicitly. +- Add/adjust integration tests when introducing new commands. +- Prefer built-in Node APIs over new packages. + +## Reporting issues +Please include: +- OS and Node version +- Xcode/Android SDK versions (if relevant) +- Exact command and output + +Thanks for helping improve agent-device. diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..e339c7e96 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Callstack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..b7e22e9b7 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# agent-device + +Unified control CLI for physical and virtual devices (iOS + Android) for AI agents. + +This project mirrors the spirit of `agent-browser`, but targets iOS simulators/devices and Android emulators/devices. + +## Current scope (v1) +- Platforms: iOS (simulator + limited device support) and Android (emulator + device). +- Core commands: `open`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `screenshot`, `close`. +- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode). +- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step). + +## Install + +```bash +npm install -g agent-device +``` + +Or use it without installing: + +```bash +npx agent-device open Settings +``` + +## Usage + +```bash +agent-device [args] [--json] +``` + +Examples: + +```bash +agent-device open Settings +agent-device press 120 320 +agent-device type "hello" +agent-device screenshot --out ./screenshot.png +agent-device close Settings +``` + +Flags: +- `--platform ios|android` +- `--device ` +- `--udid ` (iOS) +- `--serial ` (Android) +- `--out ` (screenshot) +- `--session ` +- `--verbose` for daemon and runner logs +- `--json` for structured output + +Sessions: +- `open` starts a session. +- All interaction commands require an open session. +- `close` stops the session and releases device resources. Pass an app to close it explicitly, or omit to just close the session. +- Use `--session ` to manage multiple sessions. + +iOS simulator input: +- If `simctl` input is unavailable for your Xcode version, the CLI will use the XCTest runner to perform taps and scrolls. + +## App resolution +- Bundle/package identifiers are accepted directly (e.g., `com.apple.Preferences`). +- Human-readable names are resolved when possible (e.g., `Settings`). +- Built-in aliases include `Settings` for both platforms. + +## iOS notes +- Input commands (`press`, `type`, `scroll`, etc.) are supported only on simulators in v1. +- Support depends on your Xcode version; the CLI reports `UNSUPPORTED_OPERATION` if `simctl io` lacks input operations. + +## Testing + +```bash +pnpm test +``` + +## Build + +```bash +pnpm build +``` + +Environment selectors: +- `ANDROID_DEVICE=Pixel_9_Pro_XL` or `ANDROID_SERIAL=emulator-5554` +- `IOS_DEVICE="iPhone 17 Pro"` or `IOS_UDID=` + +Test screenshots are written to: +- `test/screenshots/android-settings.png` +- `test/screenshots/ios-settings.png` + +## Contributing +See `CONTRIBUTING.md`. + +## Made with ❤️ at Callstack + +agent-device is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. Callstack is a group of React and React Native geeks. Contact us at hello@callstack.com if you need any help with these technologies or just want to say hi. diff --git a/bin/agent-device.mjs b/bin/agent-device.mjs new file mode 100755 index 000000000..3a417e8c1 --- /dev/null +++ b/bin/agent-device.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const distPath = join(here, '..', 'dist', 'src', 'bin.js'); + +if (!existsSync(distPath)) { + process.stderr.write('Missing dist build. Run `pnpm build` before using the binary.\n'); + process.exit(1); +} + +await import(pathToFileURL(distPath).href); diff --git a/docs/ios-automation.md b/docs/ios-automation.md new file mode 100644 index 000000000..ccd4ea085 --- /dev/null +++ b/docs/ios-automation.md @@ -0,0 +1,39 @@ +# iOS UI Automation Strategy (v1) + +## Goal +Provide robust element-level interactions for AI agents on iOS by running a lightweight XCUITest runner on the target simulator/device, with caching and a fallback to local builds. + +## Why this approach +- Apple’s official UI automation layer is XCUITest/XCUI, which runs as an XCTest bundle on device/simulator. +- Tools like Appium and Maestro use an on-device XCTest runner and talk to it over HTTP. +- For real devices, code signing is required, so a local build/sign step is unavoidable. + +## Experience targets +- First run: build and cache the runner via `xcodebuild`. +- Subsequent runs: reuse cached artifacts per Xcode version + runtime. +- Simulators: allow prebuilt runner when compatible. +- Devices: always require local signing. + +## Implementation plan (condensed) +1. **Runner project** + - `ios-runner/` Xcode project with one XCTest target. + - Minimal HTTP server inside tests to accept JSON commands (Maestro uses a long-running XCTest that serves HTTP and exposes view hierarchy and actions). + - Protocol is documented in `docs/ios-runner-protocol.md`. +2. **Build + cache** + - Build via `xcodebuild build-for-testing` and run via `test-without-building`. + - Cache artifacts under `~/.agent-device/ios-runner//`. +3. **Node adapter** + - Add an iOS automation adapter that: + - Ensures runner is built/available. + - Starts runner for the specific device/simulator. + - Sends commands (tap, type, swipe, find, list elements) over HTTP. +4. **Fallbacks** + - If runner not available, fall back to `simctl`/`devicectl` capabilities. + +## Notes +- Prebuilt runners can be distributed for simulators, but are sensitive to Xcode/runtime versions. +- Real devices always need user signing. + +## References +- Appium’s XCUITest driver uses `build-for-testing` + `test-without-building` and supports prebuilt runners for faster startup. +- Maestro’s iOS driver runs an XCTest with an HTTP server to serve view hierarchy and actions; it separates UI automation (XCTest) from device management (simctl/devicectl). diff --git a/docs/ios-runner-protocol.md b/docs/ios-runner-protocol.md new file mode 100644 index 000000000..71100fabb --- /dev/null +++ b/docs/ios-runner-protocol.md @@ -0,0 +1,52 @@ +# iOS Runner Protocol (v1) + +The iOS XCTest runner accepts a single HTTP request, processes one command, and exits. + +## Request + +- Method: `POST` +- Path: `/command` (not strictly validated) +- Body: JSON + +### JSON schema (conceptual) + +```json +{ + "command": "tap|type|swipe|findText|listTappables", + "appBundleId": "com.apple.Preferences", + "text": "Apps", + "x": 100, + "y": 200, + "direction": "up|down|left|right" +} +``` + +Fields by command: +- `tap`: provide either `text` or `x` + `y`. +- `type`: requires `text`. +- `swipe`: requires `direction`. +- `findText`: requires `text`. +- `listTappables`: no extra fields. + +## Response + +```json +{ + "ok": true, + "data": { "message": "tapped" } +} +``` + +Errors return: + +```json +{ + "ok": false, + "error": { "message": "..." } +} +``` + +Data payload may include: +- `message`: string +- `found`: boolean (for `findText`) +- `items`: array of strings (for `listTappables`) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj b/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..43146db28 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj @@ -0,0 +1,461 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + 20EA2EDE2F2CFC7C001CF0EF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 20EA2EBE2F2CFC7B001CF0EF /* Project object */; + proxyType = 1; + remoteGlobalIDString = 20EA2EC52F2CFC7B001CF0EF; + remoteInfo = AgentDeviceRunner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 20EA2EC62F2CFC7B001CF0EF /* AgentDeviceRunner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AgentDeviceRunner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 20EA2EDD2F2CFC7C001CF0EF /* AgentDeviceRunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AgentDeviceRunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 20EA2EC82F2CFC7B001CF0EF /* AgentDeviceRunner */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = AgentDeviceRunner; + sourceTree = ""; + }; + 20EA2EE02F2CFC7C001CF0EF /* AgentDeviceRunnerUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = AgentDeviceRunnerUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 20EA2EC32F2CFC7B001CF0EF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 20EA2EDA2F2CFC7C001CF0EF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 20EA2EBD2F2CFC7B001CF0EF = { + isa = PBXGroup; + children = ( + 20EA2EC82F2CFC7B001CF0EF /* AgentDeviceRunner */, + 20EA2EE02F2CFC7C001CF0EF /* AgentDeviceRunnerUITests */, + 20EA2EC72F2CFC7B001CF0EF /* Products */, + ); + sourceTree = ""; + }; + 20EA2EC72F2CFC7B001CF0EF /* Products */ = { + isa = PBXGroup; + children = ( + 20EA2EC62F2CFC7B001CF0EF /* AgentDeviceRunner.app */, + 20EA2EDD2F2CFC7C001CF0EF /* AgentDeviceRunnerUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 20EA2EC52F2CFC7B001CF0EF /* AgentDeviceRunner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 20EA2EE72F2CFC7C001CF0EF /* Build configuration list for PBXNativeTarget "AgentDeviceRunner" */; + buildPhases = ( + 20EA2EC22F2CFC7B001CF0EF /* Sources */, + 20EA2EC32F2CFC7B001CF0EF /* Frameworks */, + 20EA2EC42F2CFC7B001CF0EF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 20EA2EC82F2CFC7B001CF0EF /* AgentDeviceRunner */, + ); + name = AgentDeviceRunner; + packageProductDependencies = ( + ); + productName = AgentDeviceRunner; + productReference = 20EA2EC62F2CFC7B001CF0EF /* AgentDeviceRunner.app */; + productType = "com.apple.product-type.application"; + }; + 20EA2EDC2F2CFC7C001CF0EF /* AgentDeviceRunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 20EA2EED2F2CFC7C001CF0EF /* Build configuration list for PBXNativeTarget "AgentDeviceRunnerUITests" */; + buildPhases = ( + 20EA2ED92F2CFC7C001CF0EF /* Sources */, + 20EA2EDA2F2CFC7C001CF0EF /* Frameworks */, + 20EA2EDB2F2CFC7C001CF0EF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 20EA2EDF2F2CFC7C001CF0EF /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 20EA2EE02F2CFC7C001CF0EF /* AgentDeviceRunnerUITests */, + ); + name = AgentDeviceRunnerUITests; + packageProductDependencies = ( + ); + productName = AgentDeviceRunnerUITests; + productReference = 20EA2EDD2F2CFC7C001CF0EF /* AgentDeviceRunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 20EA2EBE2F2CFC7B001CF0EF /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 20EA2EC52F2CFC7B001CF0EF = { + CreatedOnToolsVersion = 26.0; + }; + 20EA2EDC2F2CFC7C001CF0EF = { + CreatedOnToolsVersion = 26.0; + TestTargetID = 20EA2EC52F2CFC7B001CF0EF; + }; + }; + }; + buildConfigurationList = 20EA2EC12F2CFC7B001CF0EF /* Build configuration list for PBXProject "AgentDeviceRunner" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 20EA2EBD2F2CFC7B001CF0EF; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 20EA2EC72F2CFC7B001CF0EF /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 20EA2EC52F2CFC7B001CF0EF /* AgentDeviceRunner */, + 20EA2EDC2F2CFC7C001CF0EF /* AgentDeviceRunnerUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 20EA2EC42F2CFC7B001CF0EF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 20EA2EDB2F2CFC7C001CF0EF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 20EA2EC22F2CFC7B001CF0EF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 20EA2ED92F2CFC7C001CF0EF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 20EA2EDF2F2CFC7C001CF0EF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 20EA2EC52F2CFC7B001CF0EF /* AgentDeviceRunner */; + targetProxy = 20EA2EDE2F2CFC7C001CF0EF /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 20EA2EE52F2CFC7C001CF0EF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 2S799L9W4M; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 20EA2EE62F2CFC7C001CF0EF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 2S799L9W4M; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 20EA2EE82F2CFC7C001CF0EF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2S799L9W4M; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.myapp.AgentDeviceRunner; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 20EA2EE92F2CFC7C001CF0EF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2S799L9W4M; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.myapp.AgentDeviceRunner; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 20EA2EEE2F2CFC7C001CF0EF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2S799L9W4M; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.myapp.AgentDeviceRunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = AgentDeviceRunner; + }; + name = Debug; + }; + 20EA2EEF2F2CFC7C001CF0EF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2S799L9W4M; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.myapp.AgentDeviceRunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = AgentDeviceRunner; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 20EA2EC12F2CFC7B001CF0EF /* Build configuration list for PBXProject "AgentDeviceRunner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 20EA2EE52F2CFC7C001CF0EF /* Debug */, + 20EA2EE62F2CFC7C001CF0EF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 20EA2EE72F2CFC7C001CF0EF /* Build configuration list for PBXNativeTarget "AgentDeviceRunner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 20EA2EE82F2CFC7C001CF0EF /* Debug */, + 20EA2EE92F2CFC7C001CF0EF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 20EA2EED2F2CFC7C001CF0EF /* Build configuration list for PBXNativeTarget "AgentDeviceRunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 20EA2EEE2F2CFC7C001CF0EF /* Debug */, + 20EA2EEF2F2CFC7C001CF0EF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 20EA2EBE2F2CFC7B001CF0EF /* Project object */; +} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/xcuserdata/thymikee.xcuserdatad/UserInterfaceState.xcuserstate b/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.xcworkspace/xcuserdata/thymikee.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..066f4d88d842753333f0b60b6baabd2398ee50e2 GIT binary patch literal 25588 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 E0EgOu=>Px# literal 0 HcmV?d00001 diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme b/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme new file mode 100644 index 000000000..4054463d7 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.swift new file mode 100644 index 000000000..f1bf5bf04 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.swift @@ -0,0 +1,17 @@ +// +// AgentDeviceRunnerApp.swift +// AgentDeviceRunner +// +// Created by Michał Pierzchała on 30/01/2026. +// + +import SwiftUI + +@main +struct AgentDeviceRunnerApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AccentColor.colorset/Contents.json b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Contents.json b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunner/ContentView.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/ContentView.swift new file mode 100644 index 000000000..a88df3254 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunner/ContentView.swift @@ -0,0 +1,24 @@ +// +// ContentView.swift +// AgentDeviceRunner +// +// Created by Michał Pierzchała on 30/01/2026. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift new file mode 100644 index 000000000..73799c390 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -0,0 +1,351 @@ +// +// Untitled.swift +// AgentDeviceRunner +// +// Created by Michał Pierzchała on 30/01/2026. +// + +import XCTest +import Network + +final class RunnerTests: XCTestCase { + private var listener: NWListener? + private var port: UInt16 = 0 + private var doneExpectation: XCTestExpectation? + private let app = XCUIApplication() + private var currentApp: XCUIApplication? + private var currentBundleId: String? + private let maxRequestBytes = 2 * 1024 * 1024 + + override func setUp() { + continueAfterFailure = false + } + + @MainActor + func testCommand() throws { + doneExpectation = expectation(description: "agent-device command handled") + app.launch() + currentApp = app + let queue = DispatchQueue(label: "agent-device.runner") + let desiredPort = resolveRunnerPort() + NSLog("AGENT_DEVICE_RUNNER_DESIRED_PORT=%d", desiredPort) + if desiredPort > 0, let port = NWEndpoint.Port(rawValue: desiredPort) { + listener = try NWListener(using: .tcp, on: port) + } else { + listener = try NWListener(using: .tcp) + } + listener?.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + NSLog("AGENT_DEVICE_RUNNER_LISTENER_READY") + if let listenerPort = self?.listener?.port { + self?.port = listenerPort.rawValue + NSLog("AGENT_DEVICE_RUNNER_PORT=%d", listenerPort.rawValue) + } else { + NSLog("AGENT_DEVICE_RUNNER_PORT_NOT_SET") + } + case .failed(let error): + NSLog("AGENT_DEVICE_RUNNER_LISTENER_FAILED=%@", String(describing: error)) + default: + break + } + } + listener?.newConnectionHandler = { [weak self] conn in + conn.start(queue: queue) + self?.handle(connection: conn) + } + listener?.start(queue: queue) + + guard let expectation = doneExpectation else { + XCTFail("runner expectation was not initialized") + return + } + NSLog("AGENT_DEVICE_RUNNER_WAITING") + let result = XCTWaiter.wait(for: [expectation], timeout: 10) + NSLog("AGENT_DEVICE_RUNNER_WAIT_RESULT=%@", String(describing: result)) + if result != .completed { + XCTFail("runner wait ended with \(result)") + } + } + + private func handle(connection: NWConnection) { + receiveRequest(connection: connection, buffer: Data()) + } + + private func receiveRequest(connection: NWConnection, buffer: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 1024 * 1024) { [weak self] data, _, _, _ in + guard let self = self, let data = data else { + connection.cancel() + return + } + if buffer.count + data.count > self.maxRequestBytes { + let response = self.jsonResponse( + status: 413, + response: Response(ok: false, error: ErrorPayload(message: "request too large")), + ) + connection.send(content: response, completion: .contentProcessed { [weak self] _ in + connection.cancel() + self?.finish() + }) + return + } + let combined = buffer + data + if let body = self.parseRequest(data: combined) { + let response = self.handleRequestBody(body) + connection.send(content: response, completion: .contentProcessed { _ in + connection.cancel() + self.finish() + }) + } else { + self.receiveRequest(connection: connection, buffer: combined) + } + } + } + + private func parseRequest(data: Data) -> Data? { + guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else { + return nil + } + let headerData = data.subdata(in: 0.. Int? { + for line in headers.split(separator: "\r\n") { + let parts = line.split(separator: ":", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } + if parts.count == 2 && parts[0].lowercased() == "content-length" { + return Int(parts[1]) + } + } + return nil + } + + private func handleRequestBody(_ body: Data) -> Data { + guard let json = String(data: body, encoding: .utf8) else { + return jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))) + } + guard let data = json.data(using: .utf8) else { + return jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))) + } + + do { + let command = try JSONDecoder().decode(Command.self, from: data) + let response = try execute(command: command) + return jsonResponse(status: 200, response: response) + } catch { + return jsonResponse(status: 500, response: Response(ok: false, error: ErrorPayload(message: "\(error)"))) + } + } + + private func execute(command: Command) throws -> Response { + if Thread.isMainThread { + return try executeOnMain(command: command) + } + var result: Result? + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue.main.async { + do { + result = .success(try self.executeOnMain(command: command)) + } catch { + result = .failure(error) + } + semaphore.signal() + } + semaphore.wait() + switch result { + case .success(let response): + return response + case .failure(let error): + throw error + case .none: + throw NSError(domain: "AgentDeviceRunner", code: 1, userInfo: [NSLocalizedDescriptionKey: "no response from main thread"]) + } + } + + private func executeOnMain(command: Command) throws -> Response { + let bundleId = command.appBundleId ?? "com.apple.Preferences" + if currentBundleId != bundleId { + let target = XCUIApplication(bundleIdentifier: bundleId) + target.launch() + currentApp = target + currentBundleId = bundleId + } + let activeApp = currentApp ?? app + _ = activeApp.waitForExistence(timeout: 5) + + switch command.command { + case .tap: + if let text = command.text { + if let element = findElement(app: activeApp, text: text) { + element.tap() + return Response(ok: true, data: DataPayload(message: "tapped")) + } + return Response(ok: false, error: ErrorPayload(message: "element not found")) + } + if let x = command.x, let y = command.y { + tapAt(app: activeApp, x: x, y: y) + return Response(ok: true, data: DataPayload(message: "tapped")) + } + return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y")) + case .type: + guard let text = command.text else { + return Response(ok: false, error: ErrorPayload(message: "type requires text")) + } + activeApp.typeText(text) + return Response(ok: true, data: DataPayload(message: "typed")) + case .swipe: + guard let direction = command.direction else { + return Response(ok: false, error: ErrorPayload(message: "swipe requires direction")) + } + swipe(app: activeApp, direction: direction) + return Response(ok: true, data: DataPayload(message: "swiped")) + case .findText: + guard let text = command.text else { + return Response(ok: false, error: ErrorPayload(message: "findText requires text")) + } + let found = findElement(app: activeApp, text: text) != nil + return Response(ok: true, data: DataPayload(found: found)) + case .listTappables: + let elements = activeApp.descendants(matching: .any).allElementsBoundByIndex + let labels = elements.compactMap { element -> String? in + guard element.isHittable else { return nil } + let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty { return nil } + let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines) + return identifier.isEmpty ? label : "\(label) [\(identifier)]" + } + let unique = Array(Set(labels)).sorted() + return Response(ok: true, data: DataPayload(items: unique)) + } + } + + private func findElement(app: XCUIApplication, text: String) -> XCUIElement? { + let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text) + let element = app.descendants(matching: .any).matching(predicate).firstMatch + return element.exists ? element : nil + } + + private func tapAt(app: XCUIApplication, x: Double, y: Double) { + let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)) + let coordinate = origin.withOffset(CGVector(dx: x, dy: y)) + coordinate.tap() + } + + private func swipe(app: XCUIApplication, direction: SwipeDirection) { + let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app + let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)) + let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + let left = target.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)) + let right = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)) + + switch direction { + case .up: + end.press(forDuration: 0.1, thenDragTo: start) + case .down: + start.press(forDuration: 0.1, thenDragTo: end) + case .left: + right.press(forDuration: 0.1, thenDragTo: left) + case .right: + left.press(forDuration: 0.1, thenDragTo: right) + } + } + + private func jsonResponse(status: Int, response: Response) -> Data { + let encoder = JSONEncoder() + let body = (try? encoder.encode(response)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + return httpResponse(status: status, body: body) + } + + private func httpResponse(status: Int, body: String) -> Data { + let headers = [ + "HTTP/1.1 \(status) OK", + "Content-Type: application/json", + "Content-Length: \(body.utf8.count)", + "Connection: close", + "", + body, + ].joined(separator: "\r\n") + return Data(headers.utf8) + } + + private func finish() { + listener?.cancel() + listener = nil + doneExpectation?.fulfill() + } +} + +private func resolveRunnerPort() -> UInt16 { + if let env = ProcessInfo.processInfo.environment["AGENT_DEVICE_RUNNER_PORT"], let port = UInt16(env) { + return port + } + for arg in CommandLine.arguments { + if arg.hasPrefix("AGENT_DEVICE_RUNNER_PORT=") { + let value = arg.replacingOccurrences(of: "AGENT_DEVICE_RUNNER_PORT=", with: "") + if let port = UInt16(value) { return port } + } + } + return 0 +} + +enum CommandType: String, Codable { + case tap + case type + case swipe + case findText + case listTappables +} + +enum SwipeDirection: String, Codable { + case up + case down + case left + case right +} + +struct Command: Codable { + let command: CommandType + let appBundleId: String? + let text: String? + let x: Double? + let y: Double? + let direction: SwipeDirection? +} + +struct Response: Codable { + let ok: Bool + let data: DataPayload? + let error: ErrorPayload? + + init(ok: Bool, data: DataPayload? = nil, error: ErrorPayload? = nil) { + self.ok = ok + self.data = data + self.error = error + } +} + +struct DataPayload: Codable { + let message: String? + let found: Bool? + let items: [String]? + + init(message: String? = nil, found: Bool? = nil, items: [String]? = nil) { + self.message = message + self.found = found + self.items = items + } +} + +struct ErrorPayload: Codable { + let message: String +} diff --git a/ios-runner/README.md b/ios-runner/README.md new file mode 100644 index 000000000..81e48e790 --- /dev/null +++ b/ios-runner/README.md @@ -0,0 +1,11 @@ +# agent-device iOS Runner + +This folder is reserved for the lightweight XCUITest runner used to provide element-level automation on iOS. + +## Intent +- Provide a minimal XCTest target that exposes UI automation over a small HTTP server. +- Allow local builds via `xcodebuild` and caching for faster subsequent runs. +- Support simulator prebuilds where compatible. + +## Status +Planned for v1 automation layer. See `docs/ios-automation.md` and `docs/ios-runner-protocol.md`. diff --git a/package.json b/package.json new file mode 100644 index 000000000..24762dbe3 --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "agent-device", + "version": "0.1.0", + "description": "Unified control plane for physical and virtual devices via an agent-driven CLI.", + "license": "MIT", + "author": "Callstack", + "type": "module", + "engines": { + "node": ">=22" + }, + "bin": { + "agent-device": "bin/agent-device.mjs" + }, + "scripts": { + "lint": "node --eval \"console.log('no lint')\"", + "build": "rslib build", + "format": "prettier --write .", + "prepublishOnly": "pnpm build", + "typecheck": "tsc -p tsconfig.json", + "test": "node --test" + }, + "files": [ + "bin", + "dist", + "src", + "README.md", + "LICENSE" + ], + "keywords": [ + "agent", + "device", + "cli", + "adb", + "simctl", + "devicectl", + "ios", + "android" + ], + "prettier": { + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 100 + }, + "dependencies": { + "@clack/prompts": "^1.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@rslib/core": "0.19.4", + "prettier": "^3.3.3", + "typescript": "^5.9.3" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 000000000..e19a8e9ba --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,466 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@clack/prompts': + specifier: ^1.0.0 + version: 1.0.0 + devDependencies: + '@rslib/core': + specifier: 0.19.4 + version: 0.19.4(typescript@5.9.3) + '@types/node': + specifier: ^22.0.0 + version: 22.19.7 + prettier: + specifier: ^3.3.3 + version: 3.8.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@ast-grep/napi-darwin-arm64@0.37.0': + resolution: {integrity: sha512-QAiIiaAbLvMEg/yBbyKn+p1gX2/FuaC0SMf7D7capm/oG4xGMzdeaQIcSosF4TCxxV+hIH4Bz9e4/u7w6Bnk3Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@ast-grep/napi-darwin-x64@0.37.0': + resolution: {integrity: sha512-zvcvdgekd4ySV3zUbUp8HF5nk5zqwiMXTuVzTUdl/w08O7JjM6XPOIVT+d2o/MqwM9rsXdzdergY5oY2RdhSPA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@ast-grep/napi-linux-arm64-gnu@0.37.0': + resolution: {integrity: sha512-L7Sj0lXy8X+BqSMgr1LB8cCoWk0rericdeu+dC8/c8zpsav5Oo2IQKY1PmiZ7H8IHoFBbURLf8iklY9wsD+cyA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@ast-grep/napi-linux-arm64-musl@0.37.0': + resolution: {integrity: sha512-LF9sAvYy6es/OdyJDO3RwkX3I82Vkfsng1sqUBcoWC1jVb1wX5YVzHtpQox9JrEhGl+bNp7FYxB4Qba9OdA5GA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@ast-grep/napi-linux-x64-gnu@0.37.0': + resolution: {integrity: sha512-TViz5/klqre6aSmJzswEIjApnGjJzstG/SE8VDWsrftMBMYt2PTu3MeluZVwzSqDao8doT/P+6U11dU05UOgxw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@ast-grep/napi-linux-x64-musl@0.37.0': + resolution: {integrity: sha512-/BcCH33S9E3ovOAEoxYngUNXgb+JLg991sdyiNP2bSoYd30a9RHrG7CYwW6fMgua3ijQ474eV6cq9yZO1bCpXg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@ast-grep/napi-win32-arm64-msvc@0.37.0': + resolution: {integrity: sha512-TjQA4cFoIEW2bgjLkaL9yqT4XWuuLa5MCNd0VCDhGRDMNQ9+rhwi9eLOWRaap3xzT7g+nlbcEHL3AkVCD2+b3A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@ast-grep/napi-win32-ia32-msvc@0.37.0': + resolution: {integrity: sha512-uNmVka8fJCdYsyOlF9aZqQMLTatEYBynjChVTzUfFMDfmZ0bihs/YTqJVbkSm8TZM7CUX82apvn50z/dX5iWRA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@ast-grep/napi-win32-x64-msvc@0.37.0': + resolution: {integrity: sha512-vCiFOT3hSCQuHHfZ933GAwnPzmL0G04JxQEsBRfqONywyT8bSdDc/ECpAfr3S9VcS4JZ9/F6tkePKW/Om2Dq2g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@ast-grep/napi@0.37.0': + resolution: {integrity: sha512-Hb4o6h1Pf6yRUAX07DR4JVY7dmQw+RVQMW5/m55GoiAT/VRoKCWBtIUPPOnqDVhbx1Cjfil9b6EDrgJsUAujEQ==} + engines: {node: '>= 10'} + + '@clack/core@1.0.0': + resolution: {integrity: sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==} + + '@clack/prompts@1.0.0': + resolution: {integrity: sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==} + + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@module-federation/error-codes@0.22.0': + resolution: {integrity: sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==} + + '@module-federation/runtime-core@0.22.0': + resolution: {integrity: sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==} + + '@module-federation/runtime-tools@0.22.0': + resolution: {integrity: sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==} + + '@module-federation/runtime@0.22.0': + resolution: {integrity: sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==} + + '@module-federation/sdk@0.22.0': + resolution: {integrity: sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==} + + '@module-federation/webpack-bundler-runtime@0.22.0': + resolution: {integrity: sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==} + + '@napi-rs/wasm-runtime@1.0.7': + resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + + '@rsbuild/core@1.7.2': + resolution: {integrity: sha512-VAFO6cM+cyg2ntxNW6g3tB2Jc5J5mpLjLluvm7VtW2uceNzyUlVv41o66Yp1t1ikxd3ljtqegViXem62JqzveA==} + engines: {node: '>=18.12.0'} + hasBin: true + + '@rslib/core@0.19.4': + resolution: {integrity: sha512-qFJKXxFGf582uNWyD+2cxKVoorsDepWn7M0VXZX8BLzaEXVBzQmQ0i9UojWicmJr3ui9nkI7WRh66FQzoyWREg==} + engines: {node: '>=18.12.0'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7 + typescript: ^5 + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + typescript: + optional: true + + '@rspack/binding-darwin-arm64@1.7.4': + resolution: {integrity: sha512-d4FTW/TkqvU9R1PsaK2tbLG1uY0gAlxy3rEiQYrFRAOVTMOFkPasypmvhwD5iWrPIhkjIi79IkgrSzRJaP2ZwA==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-x64@1.7.4': + resolution: {integrity: sha512-Oq65S5szs3+In9hVWfPksdL6EUu1+SFZK3oQINP3kMJ5zPzrdyiue+L5ClpTU/VMKVxfQTdCBsI6OVJNnaLBiA==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-linux-arm64-gnu@1.7.4': + resolution: {integrity: sha512-sTpfCraAtYZBhdw9Xx5a19OgJ/mBELTi61utZzrO3bV6BFEulvOdmnNjpgb0xv1KATtNI8YxECohUzekk1WsOA==} + cpu: [arm64] + os: [linux] + + '@rspack/binding-linux-arm64-musl@1.7.4': + resolution: {integrity: sha512-sw8jZbUe13Ry0/tnUt1pSdwkaPtSzKuveq+b6/CUT26I3DKfJQoG0uJbjj2quMe4ks3jDmoGlxuRe4D/fWUoSg==} + cpu: [arm64] + os: [linux] + + '@rspack/binding-linux-x64-gnu@1.7.4': + resolution: {integrity: sha512-1W6LU0wR/TxB+8pogt0pn0WRwbQmKfu9839p/VBuSkNdWR4aljAhYO6RxsLQLCLrDAqEyrpeYWsWJBvAJ4T/pA==} + cpu: [x64] + os: [linux] + + '@rspack/binding-linux-x64-musl@1.7.4': + resolution: {integrity: sha512-rkmu8qLnm/q8J14ZQZ04SnPNzdRNgzAoKJCTbnhCzcuL5k5e20LUFfGuS6j7Io1/UdVMOjz/u7R6b9h/qA1Scw==} + cpu: [x64] + os: [linux] + + '@rspack/binding-wasm32-wasi@1.7.4': + resolution: {integrity: sha512-6BQvLbDtUVkTN5o1QYLYKAYuXavC4ER5Vn/amJEoecbM9F25MNAv28inrXs7BQ4cHSU4WW/F4yZPGnA+jUZLyw==} + cpu: [wasm32] + + '@rspack/binding-win32-arm64-msvc@1.7.4': + resolution: {integrity: sha512-kipggu7xVPhnAkAV7koSDVbBuuMDMA4hX60DNJKTS6fId3XNHcZqWKIsWGOt0yQ6KV7I3JRRBDotKLx6uYaRWw==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@1.7.4': + resolution: {integrity: sha512-9Zdozc13AUQHqagDDHxHml1FnZZWuSj/uP+SxtlTlQaiIE9GDH3n0cUio1GUq+cBKbcXeiE3dJMGJxhiFaUsxA==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@1.7.4': + resolution: {integrity: sha512-3a/jZTUrvU340IuRcxul+ccsDtdrMaGq/vi4HNcWalL0H2xeOeuieBAV8AZqaRjmxMu8OyRcpcSrkHtN1ol/eA==} + cpu: [x64] + os: [win32] + + '@rspack/binding@1.7.4': + resolution: {integrity: sha512-BOACDXd9aTrdJgqa88KGxnTGdUdVLAClTCLhSvdNvQZIcaVLOB1qtW0TvqjZ19MxuQB/Cba5u/ILc5DNXxuDhg==} + + '@rspack/core@1.7.4': + resolution: {integrity: sha512-6QNqcsRSy1WbAGvjA2DAEx4yyAzwrvT6vd24Kv4xdZHdvF6FmcUbr5J+mLJ1jSOXvpNhZ+RzN37JQ8fSmytEtw==} + engines: {node: '>=18.12.0'} + peerDependencies: + '@swc/helpers': '>=0.5.1' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@rspack/lite-tapable@1.1.0': + resolution: {integrity: sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==} + + '@swc/helpers@0.5.18': + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/node@22.19.7': + resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} + + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + rsbuild-plugin-dts@0.19.4: + resolution: {integrity: sha512-IDJsg3m9ZT/Jm5RU2e8Hits8b3vI2OWdCKxkly5uDB2owXGWAaswEy3H6uTR9GhnJlQGzvfOTxwy3JQeltiLqQ==} + engines: {node: '>=18.12.0'} + peerDependencies: + '@microsoft/api-extractor': ^7 + '@rsbuild/core': 1.x + '@typescript/native-preview': 7.x + typescript: ^5 + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@ast-grep/napi-darwin-arm64@0.37.0': + optional: true + + '@ast-grep/napi-darwin-x64@0.37.0': + optional: true + + '@ast-grep/napi-linux-arm64-gnu@0.37.0': + optional: true + + '@ast-grep/napi-linux-arm64-musl@0.37.0': + optional: true + + '@ast-grep/napi-linux-x64-gnu@0.37.0': + optional: true + + '@ast-grep/napi-linux-x64-musl@0.37.0': + optional: true + + '@ast-grep/napi-win32-arm64-msvc@0.37.0': + optional: true + + '@ast-grep/napi-win32-ia32-msvc@0.37.0': + optional: true + + '@ast-grep/napi-win32-x64-msvc@0.37.0': + optional: true + + '@ast-grep/napi@0.37.0': + optionalDependencies: + '@ast-grep/napi-darwin-arm64': 0.37.0 + '@ast-grep/napi-darwin-x64': 0.37.0 + '@ast-grep/napi-linux-arm64-gnu': 0.37.0 + '@ast-grep/napi-linux-arm64-musl': 0.37.0 + '@ast-grep/napi-linux-x64-gnu': 0.37.0 + '@ast-grep/napi-linux-x64-musl': 0.37.0 + '@ast-grep/napi-win32-arm64-msvc': 0.37.0 + '@ast-grep/napi-win32-ia32-msvc': 0.37.0 + '@ast-grep/napi-win32-x64-msvc': 0.37.0 + + '@clack/core@1.0.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@1.0.0': + dependencies: + '@clack/core': 1.0.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@module-federation/error-codes@0.22.0': {} + + '@module-federation/runtime-core@0.22.0': + dependencies: + '@module-federation/error-codes': 0.22.0 + '@module-federation/sdk': 0.22.0 + + '@module-federation/runtime-tools@0.22.0': + dependencies: + '@module-federation/runtime': 0.22.0 + '@module-federation/webpack-bundler-runtime': 0.22.0 + + '@module-federation/runtime@0.22.0': + dependencies: + '@module-federation/error-codes': 0.22.0 + '@module-federation/runtime-core': 0.22.0 + '@module-federation/sdk': 0.22.0 + + '@module-federation/sdk@0.22.0': {} + + '@module-federation/webpack-bundler-runtime@0.22.0': + dependencies: + '@module-federation/runtime': 0.22.0 + '@module-federation/sdk': 0.22.0 + + '@napi-rs/wasm-runtime@1.0.7': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@rsbuild/core@1.7.2': + dependencies: + '@rspack/core': 1.7.4(@swc/helpers@0.5.18) + '@rspack/lite-tapable': 1.1.0 + '@swc/helpers': 0.5.18 + core-js: 3.47.0 + jiti: 2.6.1 + + '@rslib/core@0.19.4(typescript@5.9.3)': + dependencies: + '@rsbuild/core': 1.7.2 + rsbuild-plugin-dts: 0.19.4(@rsbuild/core@1.7.2)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript/native-preview' + + '@rspack/binding-darwin-arm64@1.7.4': + optional: true + + '@rspack/binding-darwin-x64@1.7.4': + optional: true + + '@rspack/binding-linux-arm64-gnu@1.7.4': + optional: true + + '@rspack/binding-linux-arm64-musl@1.7.4': + optional: true + + '@rspack/binding-linux-x64-gnu@1.7.4': + optional: true + + '@rspack/binding-linux-x64-musl@1.7.4': + optional: true + + '@rspack/binding-wasm32-wasi@1.7.4': + dependencies: + '@napi-rs/wasm-runtime': 1.0.7 + optional: true + + '@rspack/binding-win32-arm64-msvc@1.7.4': + optional: true + + '@rspack/binding-win32-ia32-msvc@1.7.4': + optional: true + + '@rspack/binding-win32-x64-msvc@1.7.4': + optional: true + + '@rspack/binding@1.7.4': + optionalDependencies: + '@rspack/binding-darwin-arm64': 1.7.4 + '@rspack/binding-darwin-x64': 1.7.4 + '@rspack/binding-linux-arm64-gnu': 1.7.4 + '@rspack/binding-linux-arm64-musl': 1.7.4 + '@rspack/binding-linux-x64-gnu': 1.7.4 + '@rspack/binding-linux-x64-musl': 1.7.4 + '@rspack/binding-wasm32-wasi': 1.7.4 + '@rspack/binding-win32-arm64-msvc': 1.7.4 + '@rspack/binding-win32-ia32-msvc': 1.7.4 + '@rspack/binding-win32-x64-msvc': 1.7.4 + + '@rspack/core@1.7.4(@swc/helpers@0.5.18)': + dependencies: + '@module-federation/runtime-tools': 0.22.0 + '@rspack/binding': 1.7.4 + '@rspack/lite-tapable': 1.1.0 + optionalDependencies: + '@swc/helpers': 0.5.18 + + '@rspack/lite-tapable@1.1.0': {} + + '@swc/helpers@0.5.18': + dependencies: + tslib: 2.8.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@22.19.7': + dependencies: + undici-types: 6.21.0 + + core-js@3.47.0: {} + + jiti@2.6.1: {} + + picocolors@1.1.1: {} + + prettier@3.8.1: {} + + rsbuild-plugin-dts@0.19.4(@rsbuild/core@1.7.2)(typescript@5.9.3): + dependencies: + '@ast-grep/napi': 0.37.0 + '@rsbuild/core': 1.7.2 + optionalDependencies: + typescript: 5.9.3 + + sisteransi@1.0.5: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/rslib.config.ts b/rslib.config.ts new file mode 100644 index 000000000..270c1353d --- /dev/null +++ b/rslib.config.ts @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { defineConfig } from '@rslib/core'; + +export default defineConfig({ + lib: [ + { + format: 'esm', + syntax: 'es2021', + dts: false, + shims: { + esm: { + __filename: true, + }, + }, + source: { + entry: { + bin: 'src/bin.ts', + daemon: 'src/daemon.ts', + }, + tsconfigPath: 'tsconfig.lib.json', + }, + output: { + distPath: { + root: path.join('dist', 'src'), + }, + minify: true, + }, + }, + ], +}); diff --git a/src/bin.ts b/src/bin.ts new file mode 100644 index 000000000..5e38b4981 --- /dev/null +++ b/src/bin.ts @@ -0,0 +1,3 @@ +import { runCli } from './cli.ts'; + +runCli(process.argv.slice(2)); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 000000000..02a69c484 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,122 @@ +import { parseArgs, usage } from './utils/args.ts'; +import { asAppError, AppError } from './utils/errors.ts'; +import { printHumanError, printJson } from './utils/output.ts'; +import { pathToFileURL } from 'node:url'; +import { sendToDaemon } from './daemon-client.ts'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export async function runCli(argv: string[]): Promise { + const parsed = parseArgs(argv); + + if (parsed.flags.help || !parsed.command) { + process.stdout.write(`${usage()}\n`); + process.exit(parsed.flags.help ? 0 : 1); + } + + const { command, positionals, flags } = parsed; + const sessionName = flags.session ?? process.env.AGENT_DEVICE_SESSION ?? 'default'; + const logTailStopper = flags.verbose && !flags.json ? startDaemonLogTail() : null; + try { + if (command === 'session') { + const sub = positionals[0] ?? 'list'; + if (sub !== 'list') { + throw new AppError('INVALID_ARGS', 'session only supports list'); + } + const response = await sendToDaemon({ + session: sessionName, + command: 'session_list', + positionals: [], + flags: {}, + }); + if (!response.ok) throw new AppError(response.error.code as any, response.error.message); + if (flags.json) printJson({ success: true, data: response.data ?? {} }); + else process.stdout.write(`${JSON.stringify(response.data ?? {}, null, 2)}\n`); + if (logTailStopper) logTailStopper(); + return; + } + + const response = await sendToDaemon({ + session: sessionName, + command: command!, + positionals, + flags, + }); + + if (response.ok) { + if (flags.json) printJson({ success: true, data: response.data ?? {} }); + if (logTailStopper) logTailStopper(); + return; + } + + throw new AppError(response.error.code as any, response.error.message, response.error.details); + } catch (err) { + const appErr = asAppError(err); + if (flags.json) { + printJson({ + success: false, + error: { code: appErr.code, message: appErr.message, details: appErr.details }, + }); + } else { + printHumanError(appErr); + if (flags.verbose) { + try { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const logPath = path.join(os.homedir(), '.agent-device', 'daemon.log'); + if (fs.existsSync(logPath)) { + const content = fs.readFileSync(logPath, 'utf8'); + const lines = content.split('\n'); + const tail = lines.slice(Math.max(0, lines.length - 200)).join('\n'); + if (tail.trim().length > 0) { + process.stderr.write(`\n[daemon log]\n${tail}\n`); + } + } + } catch { + // ignore + } + } + } + if (logTailStopper) logTailStopper(); + process.exit(1); + } +} + +const isDirectRun = pathToFileURL(process.argv[1] ?? '').href === import.meta.url; +if (isDirectRun) { + runCli(process.argv.slice(2)).catch((err) => { + const appErr = asAppError(err); + printHumanError(appErr); + process.exit(1); + }); +} + +function startDaemonLogTail(): (() => void) | null { + try { + const logPath = path.join(os.homedir(), '.agent-device', 'daemon.log'); + let offset = 0; + let stopped = false; + const interval = setInterval(() => { + if (stopped) return; + if (!fs.existsSync(logPath)) return; + const stats = fs.statSync(logPath); + if (stats.size <= offset) return; + const fd = fs.openSync(logPath, 'r'); + const buffer = Buffer.alloc(stats.size - offset); + fs.readSync(fd, buffer, 0, buffer.length, offset); + fs.closeSync(fd); + offset = stats.size; + if (buffer.length > 0) { + process.stdout.write(buffer.toString('utf8')); + } + }, 200); + return () => { + stopped = true; + clearInterval(interval); + }; + } catch { + return null; + } +} diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts new file mode 100644 index 000000000..0215359b3 --- /dev/null +++ b/src/core/dispatch.ts @@ -0,0 +1,225 @@ +import { AppError } from '../utils/errors.ts'; +import { selectDevice, type DeviceInfo } from '../utils/device.ts'; +import { listAndroidDevices } from '../platforms/android/devices.ts'; +import { ensureAdb } from '../platforms/android/index.ts'; +import { listIosDevices } from '../platforms/ios/devices.ts'; +import { getInteractor } from '../utils/interactors.ts'; +import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts'; +import { simctlSupportsInput } from '../platforms/ios/index.ts'; + +export type CommandFlags = { + platform?: 'ios' | 'android'; + device?: string; + udid?: string; + serial?: string; + out?: string; + verbose?: boolean; +}; + +export async function resolveTargetDevice(flags: CommandFlags): Promise { + const selector = { + platform: flags.platform, + deviceName: flags.device, + udid: flags.udid, + serial: flags.serial, + }; + + if (selector.platform === 'android') { + await ensureAdb(); + const devices = await listAndroidDevices(); + return await selectDevice(devices, selector); + } + + if (selector.platform === 'ios') { + const devices = await listIosDevices(); + return await selectDevice(devices, selector); + } + + const devices: DeviceInfo[] = []; + try { + devices.push(...(await listAndroidDevices())); + } catch { + // ignore + } + try { + devices.push(...(await listIosDevices())); + } catch { + // ignore + } + return await selectDevice(devices, selector); +} + +export async function dispatchCommand( + device: DeviceInfo, + command: string, + positionals: string[], + outPath?: string, + context?: { appBundleId?: string; verbose?: boolean; logPath?: string }, +): Promise | void> { + const interactor = getInteractor(device); + switch (command) { + case 'open': { + const app = positionals[0]; + if (!app) throw new AppError('INVALID_ARGS', 'open requires an app name or bundle/package id'); + await interactor.open(app); + return { app }; + } + case 'close': { + const app = positionals[0]; + if (!app) { + return { closed: 'session' }; + } + await interactor.close(app); + return { app }; + } + case 'press': { + const [x, y] = positionals.map(Number); + if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'press requires x y'); + if (device.platform === 'ios' && device.kind === 'simulator') { + const supported = await simctlSupportsInput('tap'); + if (!supported) { + await runIosRunnerCommand( + device, + { + command: 'tap', + x, + y, + appBundleId: context?.appBundleId, + }, + { verbose: context?.verbose, logPath: context?.logPath }, + ); + return { x, y }; + } + } + await interactor.tap(x, y); + return { x, y }; + } + case 'long-press': { + const x = Number(positionals[0]); + const y = Number(positionals[1]); + const durationMs = positionals[2] ? Number(positionals[2]) : undefined; + if (Number.isNaN(x) || Number.isNaN(y)) { + throw new AppError('INVALID_ARGS', 'long-press requires x y [durationMs]'); + } + await interactor.longPress(x, y, durationMs); + return { x, y, durationMs }; + } + case 'focus': { + const [x, y] = positionals.map(Number); + if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'focus requires x y'); + if (device.platform === 'ios' && device.kind === 'simulator') { + const supported = await simctlSupportsInput('tap'); + if (!supported) { + await runIosRunnerCommand( + device, + { + command: 'tap', + x, + y, + appBundleId: context?.appBundleId, + }, + { verbose: context?.verbose, logPath: context?.logPath }, + ); + return { x, y }; + } + } + await interactor.focus(x, y); + return { x, y }; + } + case 'type': { + const text = positionals.join(' '); + if (!text) throw new AppError('INVALID_ARGS', 'type requires text'); + if (device.platform === 'ios' && device.kind === 'simulator') { + const supported = await simctlSupportsInput('keyboard'); + if (!supported) { + await runIosRunnerCommand( + device, + { + command: 'type', + text, + appBundleId: context?.appBundleId, + }, + { verbose: context?.verbose, logPath: context?.logPath }, + ); + return { text }; + } + } + await interactor.type(text); + return { text }; + } + case 'fill': { + const x = Number(positionals[0]); + const y = Number(positionals[1]); + const text = positionals.slice(2).join(' '); + if (Number.isNaN(x) || Number.isNaN(y) || !text) { + throw new AppError('INVALID_ARGS', 'fill requires x y text'); + } + if (device.platform === 'ios' && device.kind === 'simulator') { + const tapSupported = await simctlSupportsInput('tap'); + const keyboardSupported = await simctlSupportsInput('keyboard'); + if (!tapSupported || !keyboardSupported) { + await runIosRunnerCommand( + device, + { + command: 'tap', + x, + y, + appBundleId: context?.appBundleId, + }, + { verbose: context?.verbose, logPath: context?.logPath }, + ); + await runIosRunnerCommand( + device, + { + command: 'type', + text, + appBundleId: context?.appBundleId, + }, + { verbose: context?.verbose, logPath: context?.logPath }, + ); + return { x, y, text }; + } + } + await interactor.fill(x, y, text); + return { x, y, text }; + } + case 'scroll': { + const direction = positionals[0]; + const amount = positionals[1] ? Number(positionals[1]) : undefined; + if (!direction) throw new AppError('INVALID_ARGS', 'scroll requires direction'); + if (device.platform === 'ios' && device.kind === 'simulator') { + const supported = await simctlSupportsInput('swipe'); + if (!supported) { + if (!['up', 'down', 'left', 'right'].includes(direction)) { + throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`); + } + await runIosRunnerCommand( + device, + { + command: 'swipe', + direction: direction as 'up' | 'down' | 'left' | 'right', + appBundleId: context?.appBundleId, + }, + { verbose: context?.verbose, logPath: context?.logPath }, + ); + return { direction, amount }; + } + } + await interactor.scroll(direction, amount); + return { direction, amount }; + } + case 'scrollintoview': { + const text = positionals.join(' '); + if (!text) throw new AppError('INVALID_ARGS', 'scrollintoview requires text'); + await interactor.scrollIntoView(text); + return { text }; + } + case 'screenshot': { + const path = outPath ?? `./screenshot-${Date.now()}.png`; + await interactor.screenshot(path); + return { path }; + } + default: + throw new AppError('INVALID_ARGS', `Unknown command: ${command}`); + } +} diff --git a/src/daemon-client.ts b/src/daemon-client.ts new file mode 100644 index 000000000..cf9231d95 --- /dev/null +++ b/src/daemon-client.ts @@ -0,0 +1,165 @@ +import net from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { AppError } from './utils/errors.ts'; +import type { CommandFlags } from './core/dispatch.ts'; +import { runCmdDetached } from './utils/exec.ts'; + +export type DaemonRequest = { + token: string; + session: string; + command: string; + positionals: string[]; + flags?: CommandFlags; +}; + +export type DaemonResponse = + | { ok: true; data?: Record } + | { ok: false; error: { code: string; message: string; details?: Record } }; + +type DaemonInfo = { port: number; token: string; pid: number; version?: string }; + +const baseDir = path.join(os.homedir(), '.agent-device'); +const infoPath = path.join(baseDir, 'daemon.json'); +const REQUEST_TIMEOUT_MS = resolveRequestTimeoutMs(); + +export async function sendToDaemon(req: Omit): Promise { + const info = await ensureDaemon(); + const request = { ...req, token: info.token }; + return await sendRequest(info, request); +} + +async function ensureDaemon(): Promise { + const existing = readDaemonInfo(); + const localVersion = readVersion(); + if (existing && existing.version === localVersion && (await canConnect(existing))) return existing; + if (existing && (existing.version !== localVersion || !(await canConnect(existing)))) { + removeDaemonInfo(); + } + + await startDaemon(); + + const start = Date.now(); + while (Date.now() - start < 5000) { + const info = readDaemonInfo(); + if (info && (await canConnect(info))) return info; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new AppError('COMMAND_FAILED', 'Failed to start daemon', { + infoPath, + hint: 'Run pnpm build, or delete ~/.agent-device/daemon.json if stale.', + }); +} + +function readDaemonInfo(): DaemonInfo | null { + if (!fs.existsSync(infoPath)) return null; + try { + const data = JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo; + if (!data.port || !data.token) return null; + return data; + } catch { + return null; + } +} + +function removeDaemonInfo(): void { + if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath); +} + +async function canConnect(info: DaemonInfo): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host: '127.0.0.1', port: info.port }, () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => { + resolve(false); + }); + }); +} + +async function startDaemon(): Promise { + const root = findProjectRoot(); + const distPath = path.join(root, 'dist', 'src', 'daemon.js'); + const srcPath = path.join(root, 'src', 'daemon.ts'); + + const useDist = fs.existsSync(distPath); + if (!useDist && !fs.existsSync(srcPath)) { + throw new AppError('COMMAND_FAILED', 'Daemon entry not found', { distPath, srcPath }); + } + const args = useDist ? [distPath] : ['--experimental-strip-types', srcPath]; + + runCmdDetached(process.execPath, args); +} + +async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host: '127.0.0.1', port: info.port }, () => { + socket.write(`${JSON.stringify(req)}\n`); + }); + const timeout = setTimeout(() => { + socket.destroy(); + reject( + new AppError('COMMAND_FAILED', 'Daemon request timed out', { timeoutMs: REQUEST_TIMEOUT_MS }), + ); + }, REQUEST_TIMEOUT_MS); + + let buffer = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk) => { + buffer += chunk; + const idx = buffer.indexOf('\n'); + if (idx === -1) return; + const line = buffer.slice(0, idx).trim(); + if (!line) return; + try { + const response = JSON.parse(line) as DaemonResponse; + socket.end(); + clearTimeout(timeout); + resolve(response); + } catch (err) { + clearTimeout(timeout); + reject(err); + } + }); + + socket.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +function readVersion(): string { + try { + const root = findProjectRoot(); + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { + version?: string; + }; + return pkg.version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} + +function resolveRequestTimeoutMs(): number { + const raw = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS; + if (!raw) return 5000; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return 5000; + return Math.max(1000, Math.floor(parsed)); +} + +function findProjectRoot(): string { + const start = path.dirname(fileURLToPath(import.meta.url)); + let current = start; + for (let i = 0; i < 6; i += 1) { + const pkgPath = path.join(current, 'package.json'); + if (fs.existsSync(pkgPath)) return current; + current = path.dirname(current); + } + return start; +} diff --git a/src/daemon.ts b/src/daemon.ts new file mode 100644 index 000000000..52ebcbd12 --- /dev/null +++ b/src/daemon.ts @@ -0,0 +1,207 @@ +import net from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { dispatchCommand, resolveTargetDevice, type CommandFlags } from './core/dispatch.ts'; +import { asAppError, AppError } from './utils/errors.ts'; +import type { DeviceInfo } from './utils/device.ts'; + +type DaemonRequest = { + token: string; + session: string; + command: string; + positionals: string[]; + flags?: CommandFlags; +}; + +type DaemonResponse = + | { ok: true; data?: Record } + | { ok: false; error: { code: string; message: string; details?: Record } }; + +type SessionState = { + name: string; + device: DeviceInfo; + createdAt: number; + appBundleId?: string; +}; + +const sessions = new Map(); +const baseDir = path.join(os.homedir(), '.agent-device'); +const infoPath = path.join(baseDir, 'daemon.json'); +const logPath = path.join(baseDir, 'daemon.log'); +const version = readVersion(); +const token = crypto.randomBytes(24).toString('hex'); + +async function handleRequest(req: DaemonRequest): Promise { + if (req.token !== token) { + return { ok: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } }; + } + + const command = req.command; + const sessionName = req.session || 'default'; + + if (command === 'session_list') { + const data = { + sessions: Array.from(sessions.values()).map((s) => ({ + name: s.name, + platform: s.device.platform, + device: s.device.name, + id: s.device.id, + createdAt: s.createdAt, + })), + }; + return { ok: true, data }; + } + + if (command === 'open') { + const device = await resolveTargetDevice(req.flags ?? {}); + let appBundleId: string | undefined; + if (device.platform === 'ios') { + try { + const { resolveIosApp } = await import('./platforms/ios/index.ts'); + appBundleId = await resolveIosApp(device, req.positionals?.[0] ?? ''); + } catch { + appBundleId = undefined; + } + } + await dispatchCommand(device, 'open', req.positionals ?? [], req.flags?.out, { + appBundleId, + verbose: req.flags?.verbose, + logPath, + }); + sessions.set(sessionName, { + name: sessionName, + device, + createdAt: Date.now(), + appBundleId, + }); + return { ok: true, data: { session: sessionName } }; + } + + if (command === 'close') { + const session = sessions.get(sessionName); + if (!session) { + return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } }; + } + if (req.positionals && req.positionals.length > 0) { + await dispatchCommand(session.device, 'close', req.positionals ?? [], req.flags?.out, { + appBundleId: session.appBundleId, + verbose: req.flags?.verbose, + logPath, + }); + } + sessions.delete(sessionName); + return { ok: true, data: { session: sessionName } }; + } + + const session = sessions.get(sessionName); + if (!session) { + return { + ok: false, + error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' }, + }; + } + + const data = await dispatchCommand(session.device, command, req.positionals ?? [], req.flags?.out, { + appBundleId: session.appBundleId, + verbose: req.flags?.verbose, + logPath, + }); + return { ok: true, data: data ?? {} }; +} + +function writeInfo(port: number): void { + if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(logPath, ''); + fs.writeFileSync( + infoPath, + JSON.stringify({ port, token, pid: process.pid, version }, null, 2), + { + mode: 0o600, + }, + ); +} + +function removeInfo(): void { + if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath); +} + +function start(): void { + const server = net.createServer((socket) => { + let buffer = ''; + socket.setEncoding('utf8'); + socket.on('data', async (chunk) => { + buffer += chunk; + let idx = buffer.indexOf('\n'); + while (idx !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (line.length === 0) { + idx = buffer.indexOf('\n'); + continue; + } + let response: DaemonResponse; + try { + const req = JSON.parse(line) as DaemonRequest; + response = await handleRequest(req); + } catch (err) { + const appErr = asAppError(err); + response = { ok: false, error: { code: appErr.code, message: appErr.message } }; + } + socket.write(`${JSON.stringify(response)}\n`); + idx = buffer.indexOf('\n'); + } + }); + }); + + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (typeof address === 'object' && address?.port) { + writeInfo(address.port); + process.stdout.write(`AGENT_DEVICE_DAEMON_PORT=${address.port}\n`); + } + }); + + const shutdown = () => { + server.close(() => { + removeInfo(); + process.exit(0); + }); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + process.on('SIGHUP', shutdown); + process.on('uncaughtException', (err) => { + const appErr = err instanceof AppError ? err : asAppError(err); + process.stderr.write(`Daemon error: ${appErr.message}\n`); + shutdown(); + }); +} + +start(); + +function readVersion(): string { + try { + const root = findProjectRoot(); + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { + version?: string; + }; + return pkg.version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} + +function findProjectRoot(): string { + const start = path.dirname(fileURLToPath(import.meta.url)); + let current = start; + for (let i = 0; i < 6; i += 1) { + const pkgPath = path.join(current, 'package.json'); + if (fs.existsSync(pkgPath)) return current; + current = path.dirname(current); + } + return start; +} diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts new file mode 100644 index 000000000..3807da6e3 --- /dev/null +++ b/src/platforms/android/devices.ts @@ -0,0 +1,59 @@ +import { runCmd, whichCmd } from '../../utils/exec.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; + +export async function listAndroidDevices(): Promise { + const adbAvailable = await whichCmd('adb'); + if (!adbAvailable) { + throw new AppError('TOOL_MISSING', 'adb not found in PATH'); + } + + const result = await runCmd('adb', ['devices', '-l']); + const lines = result.stdout.split('\n').map((l: string) => l.trim()); + const devices: DeviceInfo[] = []; + + for (const line of lines) { + if (!line || line.startsWith('List of devices')) continue; + const parts = line.split(/\s+/); + const serial = parts[0]; + const state = parts[1]; + if (state !== 'device') continue; + + const modelPart = parts.find((p: string) => p.startsWith('model:')) ?? ''; + const rawModel = modelPart.replace('model:', '').replace(/_/g, ' ').trim(); + let name = rawModel || serial; + + if (serial.startsWith('emulator-')) { + const avd = await runCmd('adb', ['-s', serial, 'emu', 'avd', 'name'], { + allowFailure: true, + }); + const avdName = (avd.stdout as string).trim(); + if (avd.exitCode === 0 && avdName) { + name = avdName.replace(/_/g, ' '); + } + } + + const booted = await isAndroidBooted(serial); + + devices.push({ + platform: 'android', + id: serial, + name, + kind: serial.startsWith('emulator-') ? 'emulator' : 'device', + booted, + }); + } + + return devices; +} + +async function isAndroidBooted(serial: string): Promise { + try { + const result = await runCmd('adb', ['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], { + allowFailure: true, + }); + return (result.stdout as string).trim() === '1'; + } catch { + return false; + } +} diff --git a/src/platforms/android/index.ts b/src/platforms/android/index.ts new file mode 100644 index 000000000..d7d259b55 --- /dev/null +++ b/src/platforms/android/index.ts @@ -0,0 +1,251 @@ +import { promises as fs } from 'node:fs'; +import { runCmd, whichCmd } from '../../utils/exec.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; + +const ALIASES: Record = { + settings: { type: 'intent', value: 'android.settings.SETTINGS' }, +}; + +function adbArgs(device: DeviceInfo, args: string[]): string[] { + return ['-s', device.id, ...args]; +} + +export async function resolveAndroidApp( + device: DeviceInfo, + app: string, +): Promise<{ type: 'intent' | 'package'; value: string }> { + const trimmed = app.trim(); + if (trimmed.includes('.')) return { type: 'package', value: trimmed }; + + const alias = ALIASES[trimmed.toLowerCase()]; + if (alias) return alias; + + const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages'])); + const packages = result.stdout + .split('\n') + .map((line: string) => line.replace('package:', '').trim()) + .filter(Boolean); + + const matches = packages.filter((pkg: string) => + pkg.toLowerCase().includes(trimmed.toLowerCase()), + ); + if (matches.length === 1) { + return { type: 'package', value: matches[0] }; + } + + if (matches.length > 1) { + throw new AppError('INVALID_ARGS', `Multiple packages matched "${app}"`, { matches }); + } + + throw new AppError('APP_NOT_INSTALLED', `No package found matching "${app}"`); +} + +export async function openAndroidApp(device: DeviceInfo, app: string): Promise { + const resolved = await resolveAndroidApp(device, app); + if (resolved.type === 'intent') { + await runCmd('adb', adbArgs(device, ['shell', 'am', 'start', '-a', resolved.value])); + return; + } + await runCmd( + 'adb', + adbArgs(device, [ + 'shell', + 'monkey', + '-p', + resolved.value, + '-c', + 'android.intent.category.LAUNCHER', + '1', + ]), + ); +} + +export async function closeAndroidApp(device: DeviceInfo, app: string): Promise { + const trimmed = app.trim(); + if (trimmed.toLowerCase() === 'settings') { + await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', 'com.android.settings'])); + return; + } + const resolved = await resolveAndroidApp(device, app); + if (resolved.type === 'intent') { + throw new AppError('INVALID_ARGS', 'Close requires a package name, not an intent'); + } + await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', resolved.value])); +} + +export async function pressAndroid(device: DeviceInfo, x: number, y: number): Promise { + await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)])); +} + +export async function longPressAndroid( + device: DeviceInfo, + x: number, + y: number, + durationMs = 800, +): Promise { + await runCmd( + 'adb', + adbArgs(device, [ + 'shell', + 'input', + 'swipe', + String(x), + String(y), + String(x), + String(y), + String(durationMs), + ]), + ); +} + +export async function typeAndroid(device: DeviceInfo, text: string): Promise { + const encoded = text.replace(/ /g, '%s'); + await runCmd('adb', adbArgs(device, ['shell', 'input', 'text', encoded])); +} + +export async function focusAndroid(device: DeviceInfo, x: number, y: number): Promise { + await pressAndroid(device, x, y); +} + +export async function fillAndroid( + device: DeviceInfo, + x: number, + y: number, + text: string, +): Promise { + await focusAndroid(device, x, y); + await typeAndroid(device, text); +} + +export async function scrollAndroid( + device: DeviceInfo, + direction: string, + amount = 0.6, +): Promise { + const size = await getAndroidScreenSize(device); + const { width, height } = size; + const distanceX = Math.floor(width * amount); + const distanceY = Math.floor(height * amount); + + const centerX = Math.floor(width / 2); + const centerY = Math.floor(height / 2); + + let x1 = centerX; + let y1 = centerY; + let x2 = centerX; + let y2 = centerY; + + switch (direction) { + case 'up': + y1 = centerY + Math.floor(distanceY / 2); + y2 = centerY - Math.floor(distanceY / 2); + break; + case 'down': + y1 = centerY - Math.floor(distanceY / 2); + y2 = centerY + Math.floor(distanceY / 2); + break; + case 'left': + x1 = centerX + Math.floor(distanceX / 2); + x2 = centerX - Math.floor(distanceX / 2); + break; + case 'right': + x1 = centerX - Math.floor(distanceX / 2); + x2 = centerX + Math.floor(distanceX / 2); + break; + default: + throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`); + } + + await runCmd( + 'adb', + adbArgs(device, [ + 'shell', + 'input', + 'swipe', + String(x1), + String(y1), + String(x2), + String(y2), + '300', + ]), + ); +} + +export async function scrollIntoViewAndroid(device: DeviceInfo, text: string): Promise { + const maxAttempts = 8; + for (let i = 0; i < maxAttempts; i += 1) { + let xml = ''; + try { + xml = await dumpUiHierarchy(device); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new AppError('UNSUPPORTED_OPERATION', `uiautomator dump failed: ${message}`); + } + if (findBounds(xml, text)) return; + await scrollAndroid(device, 'down', 0.5); + } + throw new AppError( + 'COMMAND_FAILED', + `Could not find element containing "${text}" after scrolling`, + ); +} + +export async function screenshotAndroid(device: DeviceInfo, outPath: string): Promise { + const result = await runCmd('adb', adbArgs(device, ['exec-out', 'screencap', '-p']), { + binaryStdout: true, + }); + if (!result.stdoutBuffer) { + throw new AppError('COMMAND_FAILED', 'Failed to capture screenshot'); + } + await fs.writeFile(outPath, result.stdoutBuffer); +} + +export async function ensureAdb(): Promise { + const adbAvailable = await whichCmd('adb'); + if (!adbAvailable) throw new AppError('TOOL_MISSING', 'adb not found in PATH'); +} + +async function getAndroidScreenSize( + device: DeviceInfo, +): Promise<{ width: number; height: number }> { + const result = await runCmd('adb', adbArgs(device, ['shell', 'wm', 'size'])); + const match = result.stdout.match(/Physical size:\s*(\d+)x(\d+)/); + if (!match) throw new AppError('COMMAND_FAILED', 'Unable to read screen size'); + return { width: Number(match[1]), height: Number(match[2]) }; +} + +async function dumpUiHierarchy(device: DeviceInfo): Promise { + await runCmd( + 'adb', + adbArgs(device, ['shell', 'uiautomator', 'dump', '/sdcard/window_dump.xml']), + ); + const result = await runCmd('adb', adbArgs(device, ['shell', 'cat', '/sdcard/window_dump.xml'])); + return result.stdout; +} + +function findBounds(xml: string, query: string): { x: number; y: number } | null { + const q = query.toLowerCase(); + const nodeRegex = /]+>/g; + let match = nodeRegex.exec(xml); + while (match) { + const node = match[0]; + const textMatch = /text="([^"]*)"/.exec(node); + const descMatch = /content-desc="([^"]*)"/.exec(node); + const textVal = (textMatch?.[1] ?? '').toLowerCase(); + const descVal = (descMatch?.[1] ?? '').toLowerCase(); + if (textVal.includes(q) || descVal.includes(q)) { + const boundsMatch = /bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(node); + if (boundsMatch) { + const x1 = Number(boundsMatch[1]); + const y1 = Number(boundsMatch[2]); + const x2 = Number(boundsMatch[3]); + const y2 = Number(boundsMatch[4]); + return { x: Math.floor((x1 + x2) / 2), y: Math.floor((y1 + y2) / 2) }; + } + return { x: 0, y: 0 }; + } + match = nodeRegex.exec(xml); + } + return null; +} diff --git a/src/platforms/ios/devices.ts b/src/platforms/ios/devices.ts new file mode 100644 index 000000000..ab70426be --- /dev/null +++ b/src/platforms/ios/devices.ts @@ -0,0 +1,65 @@ +import { runCmd, whichCmd } from '../../utils/exec.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; + +export async function listIosDevices(): Promise { + if (process.platform !== 'darwin') { + throw new AppError('UNSUPPORTED_PLATFORM', 'iOS tools are only available on macOS'); + } + + const simctlAvailable = await whichCmd('xcrun'); + if (!simctlAvailable) { + throw new AppError('TOOL_MISSING', 'xcrun not found in PATH'); + } + + const devices: DeviceInfo[] = []; + + const simResult = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j']); + try { + const payload = JSON.parse(simResult.stdout as string) as { + devices: Record< + string, + { name: string; udid: string; state: string; isAvailable: boolean }[] + >; + }; + for (const runtimes of Object.values(payload.devices)) { + for (const device of runtimes) { + if (!device.isAvailable) continue; + devices.push({ + platform: 'ios', + id: device.udid, + name: device.name, + kind: 'simulator', + booted: device.state === 'Booted', + }); + } + } + } catch (err) { + throw new AppError('COMMAND_FAILED', 'Failed to parse simctl devices JSON', undefined, err); + } + + const devicectlAvailable = await whichCmd('xcrun'); + if (devicectlAvailable) { + try { + const result = await runCmd('xcrun', ['devicectl', 'list', 'devices', '--json']); + const payload = JSON.parse(result.stdout as string) as { + devices: { identifier: string; name: string; platform: string }[]; + }; + for (const device of payload.devices ?? []) { + if (device.platform?.toLowerCase().includes('ios')) { + devices.push({ + platform: 'ios', + id: device.identifier, + name: device.name, + kind: 'device', + booted: true, + }); + } + } + } catch { + // Ignore devicectl failures; simulators are still supported. + } + } + + return devices; +} diff --git a/src/platforms/ios/index.ts b/src/platforms/ios/index.ts new file mode 100644 index 000000000..1b44b2a64 --- /dev/null +++ b/src/platforms/ios/index.ts @@ -0,0 +1,322 @@ +import { runCmd } from '../../utils/exec.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; + +const ALIASES: Record = { + settings: 'com.apple.Preferences', +}; + +export async function resolveIosApp(device: DeviceInfo, app: string): Promise { + const trimmed = app.trim(); + if (trimmed.includes('.')) return trimmed; + + const alias = ALIASES[trimmed.toLowerCase()]; + if (alias) return alias; + + if (device.kind === 'simulator') { + const list = await listSimulatorApps(device); + const matches = list.filter((entry) => entry.name.toLowerCase() === trimmed.toLowerCase()); + if (matches.length === 1) return matches[0].bundleId; + if (matches.length > 1) { + throw new AppError('INVALID_ARGS', `Multiple apps matched "${app}"`, { matches }); + } + } + + throw new AppError('APP_NOT_INSTALLED', `No app found matching "${app}"`); +} + +export async function openIosApp(device: DeviceInfo, app: string): Promise { + const bundleId = await resolveIosApp(device, app); + if (device.kind === 'simulator') { + await ensureBootedSimulator(device); + await runCmd('xcrun', ['simctl', 'launch', device.id, bundleId]); + return; + } + await runCmd('xcrun', [ + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + device.id, + bundleId, + ]); +} + +export async function closeIosApp(device: DeviceInfo, app: string): Promise { + const bundleId = await resolveIosApp(device, app); + if (device.kind === 'simulator') { + await ensureBootedSimulator(device); + const result = await runCmd('xcrun', ['simctl', 'terminate', device.id, bundleId], { + allowFailure: true, + }); + if (result.exitCode !== 0) { + const stderr = result.stderr.toLowerCase(); + if (stderr.includes('found nothing to terminate')) return; + throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, { + cmd: 'xcrun', + args: ['simctl', 'terminate', device.id, bundleId], + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + return; + } + await runCmd('xcrun', [ + 'devicectl', + 'device', + 'process', + 'terminate', + '--device', + device.id, + bundleId, + ]); +} + +export async function pressIos(device: DeviceInfo, x: number, y: number): Promise { + ensureSimulator(device, 'press'); + await ensureBootedSimulator(device); + await ensureSimulatorInputSupported('tap'); + await runCmd('xcrun', ['simctl', 'io', device.id, 'tap', String(x), String(y)]); +} + +export async function longPressIos( + device: DeviceInfo, + x: number, + y: number, + durationMs = 800, +): Promise { + ensureSimulator(device, 'long-press'); + await ensureBootedSimulator(device); + await ensureSimulatorInputSupported('swipe'); + const durationSec = Math.max(0.1, durationMs / 1000).toFixed(2); + await runCmd('xcrun', [ + 'simctl', + 'io', + device.id, + 'swipe', + String(x), + String(y), + String(x), + String(y), + '--duration', + durationSec, + ]); +} + +export async function focusIos(device: DeviceInfo, x: number, y: number): Promise { + await pressIos(device, x, y); +} + +export async function typeIos(device: DeviceInfo, text: string): Promise { + ensureSimulator(device, 'type'); + await ensureBootedSimulator(device); + await ensureSimulatorInputSupported('keyboard'); + await runCmd('xcrun', ['simctl', 'io', device.id, 'keyboard', text]); +} + +export async function fillIos( + device: DeviceInfo, + x: number, + y: number, + text: string, +): Promise { + await focusIos(device, x, y); + await typeIos(device, text); +} + +export async function scrollIos( + device: DeviceInfo, + direction: string, + amount = 0.6, +): Promise { + ensureSimulator(device, 'scroll'); + await ensureBootedSimulator(device); + await ensureSimulatorInputSupported('swipe'); + const size = await getSimulatorScreenSize(device); + const { width, height } = size; + const distanceX = Math.floor(width * amount); + const distanceY = Math.floor(height * amount); + + const centerX = Math.floor(width / 2); + const centerY = Math.floor(height / 2); + + let x1 = centerX; + let y1 = centerY; + let x2 = centerX; + let y2 = centerY; + + switch (direction) { + case 'up': + y1 = centerY + Math.floor(distanceY / 2); + y2 = centerY - Math.floor(distanceY / 2); + break; + case 'down': + y1 = centerY - Math.floor(distanceY / 2); + y2 = centerY + Math.floor(distanceY / 2); + break; + case 'left': + x1 = centerX + Math.floor(distanceX / 2); + x2 = centerX - Math.floor(distanceX / 2); + break; + case 'right': + x1 = centerX - Math.floor(distanceX / 2); + x2 = centerX + Math.floor(distanceX / 2); + break; + default: + throw new AppError('INVALID_ARGS', `Unknown direction: ${direction}`); + } + + await runCmd('xcrun', [ + 'simctl', + 'io', + device.id, + 'swipe', + String(x1), + String(y1), + String(x2), + String(y2), + '--duration', + '0.3', + ]); +} + +export async function scrollIntoViewIos(text: string): Promise { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `scrollintoview is not supported on iOS without UI automation (${text})`, + ); +} + +export async function screenshotIos(device: DeviceInfo, outPath: string): Promise { + if (device.kind === 'simulator') { + await ensureBootedSimulator(device); + await runCmd('xcrun', ['simctl', 'io', device.id, 'screenshot', outPath]); + return; + } + await runCmd('xcrun', ['devicectl', 'device', 'screenshot', '--device', device.id, outPath]); +} + +function ensureSimulator(device: DeviceInfo, command: string): void { + if (device.kind !== 'simulator') { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `${command} is only supported on iOS simulators in v1`, + ); + } +} + +async function listSimulatorApps( + device: DeviceInfo, +): Promise<{ bundleId: string; name: string }[]> { + const result = await runCmd('xcrun', ['simctl', 'listapps', device.id], { allowFailure: true }); + const stdout = result.stdout as string; + if (!stdout.trim().startsWith('{')) return []; + try { + const payload = JSON.parse(stdout) as Record< + string, + { CFBundleDisplayName?: string; CFBundleName?: string } + >; + return Object.entries(payload).map(([bundleId, info]) => ({ + bundleId, + name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId, + })); + } catch { + return []; + } +} + +async function getSimulatorScreenSize( + device: DeviceInfo, +): Promise<{ width: number; height: number }> { + await ensureBootedSimulator(device); + const result = await runCmd('xcrun', ['simctl', 'io', device.id, 'status-bar', '--list'], { + allowFailure: true, + }); + const match = (result.stdout as string).match(/(\d+)x(\d+)/); + if (match) return { width: Number(match[1]), height: Number(match[2]) }; + return { width: 1170, height: 2532 }; +} + +type SimctlIoCapabilities = { + tap: boolean; + swipe: boolean; + keyboard: boolean; +}; + +let cachedIoCaps: SimctlIoCapabilities | null = null; + +async function ensureSimulatorInputSupported(op: keyof SimctlIoCapabilities): Promise { + const caps = await getSimctlIoCapabilities(); + if (!caps[op]) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `iOS simulator input is not supported by this Xcode version (missing ${op})`, + ); + } +} + +export async function simctlSupportsInput(op: keyof SimctlIoCapabilities): Promise { + const caps = await getSimctlIoCapabilities(); + return caps[op]; +} + +async function getSimctlIoCapabilities(): Promise { + if (cachedIoCaps) return cachedIoCaps; + const result = await runCmd('xcrun', ['simctl', 'io'], { allowFailure: true }); + const stderr = (result.stderr ?? '').toString(); + const ops = extractIoOperations(stderr); + cachedIoCaps = { + tap: ops.has('tap'), + swipe: ops.has('swipe'), + keyboard: ops.has('keyboard'), + }; + return cachedIoCaps; +} + +function extractIoOperations(text: string): Set { + const ops = new Set(); + const lines = text.split('\n'); + let inOps = false; + for (const line of lines) { + if (line.toLowerCase().includes('supported operations')) { + inOps = true; + continue; + } + if (!inOps) continue; + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('Example:')) break; + const op = trimmed.split(/\s+/)[0]; + if (op) ops.add(op); + } + return ops; +} + +async function ensureBootedSimulator(device: DeviceInfo): Promise { + if (device.kind !== 'simulator') return; + const state = await getSimulatorState(device.id); + if (state === 'Booted') return; + await runCmd('xcrun', ['simctl', 'boot', device.id], { allowFailure: true }); + await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], { allowFailure: true }); +} + +async function getSimulatorState(udid: string): Promise { + const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], { + allowFailure: true, + }); + if (result.exitCode !== 0) return null; + try { + const payload = JSON.parse(result.stdout as string) as { + devices: Record; + }; + for (const runtime of Object.values(payload.devices ?? {})) { + const match = runtime.find((d) => d.udid === udid); + if (match) return match.state; + } + } catch { + return null; + } + return null; +} diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts new file mode 100644 index 000000000..c7854522f --- /dev/null +++ b/src/platforms/ios/runner-client.ts @@ -0,0 +1,443 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { AppError } from '../../utils/errors.ts'; +import { runCmd, runCmdStreaming } from '../../utils/exec.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import net from 'node:net'; + +export type RunnerCommand = { + command: 'tap' | 'type' | 'swipe' | 'findText' | 'listTappables'; + appBundleId?: string; + text?: string; + x?: number; + y?: number; + direction?: 'up' | 'down' | 'left' | 'right'; +}; + +export async function runIosRunnerCommand( + device: DeviceInfo, + command: RunnerCommand, + options: { verbose?: boolean; logPath?: string } = {}, +): Promise> { + if (device.kind !== 'simulator') { + throw new AppError('UNSUPPORTED_OPERATION', 'iOS runner only supports simulators in v1'); + } + + await ensureBooted(device.id); + const xctestrun = await ensureXctestrun(device.id, options); + + const port = await getFreePort(); + const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv( + xctestrun, + { AGENT_DEVICE_RUNNER_PORT: String(port) }, + `port-${port}`, + ); + const testPromise = runCmdStreaming( + 'xcodebuild', + [ + 'test-without-building', + '-only-testing', + 'AgentDeviceRunnerUITests/RunnerTests/testCommand', + '-parallel-testing-enabled', + 'NO', + '-maximum-concurrent-test-simulator-destinations', + '1', + '-xctestrun', + xctestrunPath, + '-destination', + `platform=iOS Simulator,id=${device.id}`, + ], + { + onStdoutChunk: (chunk) => { + logChunk(chunk, options.logPath, options.verbose); + }, + onStderrChunk: (chunk) => { + logChunk(chunk, options.logPath, options.verbose); + }, + allowFailure: true, + env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) }, + }, + ); + + let response: Response | null = null; + let text = ''; + let testResult: Awaited | null = null; + try { + response = await waitForRunner(device, port, command, options.logPath); + text = await response.text(); + testResult = await testPromise; + } finally { + cleanupTempFile(xctestrunPath); + cleanupTempFile(jsonPath); + } + + let json: any = {}; + try { + json = JSON.parse(text); + } catch { + throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text }); + } + + if (!json.ok) { + throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', { + runner: json, + xcodebuild: testResult + ? { + exitCode: testResult.exitCode, + stdout: testResult.stdout, + stderr: testResult.stderr, + } + : { exitCode: 1, stdout: '', stderr: '' }, + logPath: options.logPath, + }); + } + + return json.data ?? {}; +} + +async function ensureBooted(udid: string): Promise { + await runCmd('xcrun', ['simctl', 'bootstatus', udid, '-b'], { allowFailure: true }); +} + +async function ensureXctestrun( + udid: string, + options: { verbose?: boolean; logPath?: string }, +): Promise { + const base = path.join(os.homedir(), '.agent-device', 'ios-runner'); + const derived = path.join(base, 'derived'); + if (shouldCleanDerived()) { + try { + fs.rmSync(derived, { recursive: true, force: true }); + } catch { + // ignore + } + } + const existing = findXctestrun(derived); + if (existing) return existing; + + const projectRoot = findProjectRoot(); + const projectPath = path.join(projectRoot, 'ios-runner', 'AgentDeviceRunner', 'AgentDeviceRunner.xcodeproj'); + + if (!fs.existsSync(projectPath)) { + throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath }); + } + + try { + await runCmdStreaming( + 'xcodebuild', + [ + 'build-for-testing', + '-project', + projectPath, + '-scheme', + 'AgentDeviceRunner', + '-parallel-testing-enabled', + 'NO', + '-maximum-concurrent-test-simulator-destinations', + '1', + '-destination', + `platform=iOS Simulator,id=${udid}`, + '-derivedDataPath', + derived, + ], + { + onStdoutChunk: (chunk) => { + logChunk(chunk, options.logPath, options.verbose); + }, + onStderrChunk: (chunk) => { + logChunk(chunk, options.logPath, options.verbose); + }, + }, + ); + } catch (err) { + const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err)); + throw new AppError('COMMAND_FAILED', 'xcodebuild build-for-testing failed', { + error: appErr.message, + details: appErr.details, + logPath: options.logPath, + }); + } + + const built = findXctestrun(derived); + if (!built) { + throw new AppError('COMMAND_FAILED', 'Failed to locate .xctestrun after build'); + } + return built; +} + +function findXctestrun(root: string): string | null { + if (!fs.existsSync(root)) return null; + const candidates: { path: string; mtimeMs: number }[] = []; + const stack: string[] = [root]; + while (stack.length > 0) { + const current = stack.pop() as string; + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + continue; + } + if (entry.isFile() && entry.name.endsWith('.xctestrun')) { + try { + const stat = fs.statSync(full); + candidates.push({ path: full, mtimeMs: stat.mtimeMs }); + } catch { + // ignore + } + } + } + } + if (candidates.length === 0) return null; + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + return candidates[0]?.path ?? null; +} + +function findProjectRoot(): string { + const start = path.dirname(fileURLToPath(import.meta.url)); + let current = start; + for (let i = 0; i < 6; i += 1) { + const pkgPath = path.join(current, 'package.json'); + if (fs.existsSync(pkgPath)) return current; + current = path.dirname(current); + } + return start; +} + +function logChunk(chunk: string, logPath?: string, verbose?: boolean): void { + if (logPath) { + fs.appendFileSync(logPath, chunk); + } + if (verbose) { + process.stderr.write(chunk); + } +} + +function shouldCleanDerived(): boolean { + const value = process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED; + if (!value) return false; + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); +} + +async function waitForRunner( + device: DeviceInfo, + port: number, + command: RunnerCommand, + logPath?: string, +): Promise { + if (logPath) { + await waitForRunnerReady(logPath, 4000); + } + const start = Date.now(); + let lastError: unknown = null; + while (Date.now() - start < 8000) { + try { + const response = await fetch(`http://127.0.0.1:${port}/command`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(command), + }); + return response; + } catch (err) { + lastError = err; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + if (device.kind === 'simulator') { + const simResponse = await postCommandViaSimulator(device.id, port, command); + return new Response(simResponse.body, { status: simResponse.status }); + } + const fallbackPort = logPath ? extractPortFromLog(logPath) : null; + if (fallbackPort && fallbackPort !== port) { + try { + const response = await fetch(`http://127.0.0.1:${fallbackPort}/command`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(command), + }); + return response; + } catch (err) { + lastError = err; + } + } + + throw new AppError('COMMAND_FAILED', 'Runner did not accept connection', { + port, + fallbackPort, + logPath, + lastError: lastError ? String(lastError) : undefined, + }); +} + +async function postCommandViaSimulator( + udid: string, + port: number, + command: RunnerCommand, +): Promise<{ status: number; body: string }> { + const payload = JSON.stringify(command); + const result = await runCmd( + 'xcrun', + [ + 'simctl', + 'spawn', + udid, + '/usr/bin/curl', + '-s', + '-X', + 'POST', + '-H', + 'Content-Type: application/json', + '--data', + payload, + `http://127.0.0.1:${port}/command`, + ], + { allowFailure: true }, + ); + const body = result.stdout as string; + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Runner did not accept connection (simctl spawn)', { + port, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + } + return { status: 200, body }; +} + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(); + if (typeof address === 'object' && address?.port) { + resolve(address.port); + } else { + reject(new AppError('COMMAND_FAILED', 'Failed to allocate port')); + } + }); + server.on('error', reject); + }); +} + +async function waitForRunnerReady(logPath: string, timeoutMs: number): Promise { + if (!fs.existsSync(logPath)) return; + const start = Date.now(); + let offset = 0; + while (Date.now() - start < timeoutMs) { + if (!fs.existsSync(logPath)) return; + const stats = fs.statSync(logPath); + if (stats.size > offset) { + const fd = fs.openSync(logPath, 'r'); + const buffer = Buffer.alloc(stats.size - offset); + fs.readSync(fd, buffer, 0, buffer.length, offset); + fs.closeSync(fd); + offset = stats.size; + const text = buffer.toString('utf8'); + if ( + text.includes('AGENT_DEVICE_RUNNER_LISTENER_READY') || + text.includes('AGENT_DEVICE_RUNNER_PORT=') + ) { + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +function extractPortFromLog(logPath: string): number | null { + try { + if (!fs.existsSync(logPath)) return null; + const text = fs.readFileSync(logPath, 'utf8'); + const match = text.match(/AGENT_DEVICE_RUNNER_PORT=(\d+)/); + if (match) return Number(match[1]); + } catch { + return null; + } + return null; +} + +async function prepareXctestrunWithEnv( + xctestrunPath: string, + envVars: Record, + suffix: string, +): Promise<{ xctestrunPath: string; jsonPath: string }> { + const dir = path.dirname(xctestrunPath); + const safeSuffix = suffix.replace(/[^a-zA-Z0-9._-]/g, '_'); + const tmpJsonPath = path.join(dir, `AgentDeviceRunner.env.${safeSuffix}.json`); + const tmpXctestrunPath = path.join(dir, `AgentDeviceRunner.env.${safeSuffix}.xctestrun`); + + const jsonResult = await runCmd('plutil', ['-convert', 'json', '-o', '-', xctestrunPath], { + allowFailure: true, + }); + if (jsonResult.exitCode !== 0 || !jsonResult.stdout.trim()) { + throw new AppError('COMMAND_FAILED', 'Failed to read xctestrun plist', { + xctestrunPath, + stderr: jsonResult.stderr, + }); + } + + let parsed: Record; + try { + parsed = JSON.parse(jsonResult.stdout) as Record; + } catch (err) { + throw new AppError('COMMAND_FAILED', 'Failed to parse xctestrun JSON', { + xctestrunPath, + error: String(err), + }); + } + + const applyEnvToTarget = (target: Record) => { + target.EnvironmentVariables = { ...(target.EnvironmentVariables ?? {}), ...envVars }; + target.UITestEnvironmentVariables = { ...(target.UITestEnvironmentVariables ?? {}), ...envVars }; + target.UITargetAppEnvironmentVariables = { + ...(target.UITargetAppEnvironmentVariables ?? {}), + ...envVars, + }; + target.TestingEnvironmentVariables = { ...(target.TestingEnvironmentVariables ?? {}), ...envVars }; + }; + + const configs = parsed.TestConfigurations; + if (Array.isArray(configs)) { + for (const config of configs) { + if (!config || typeof config !== 'object') continue; + const targets = config.TestTargets; + if (!Array.isArray(targets)) continue; + for (const target of targets) { + if (!target || typeof target !== 'object') continue; + applyEnvToTarget(target); + } + } + } + + for (const [key, value] of Object.entries(parsed)) { + if (value && typeof value === 'object' && value.TestBundlePath) { + applyEnvToTarget(value); + parsed[key] = value; + } + } + + fs.writeFileSync(tmpJsonPath, JSON.stringify(parsed, null, 2)); + const plistResult = await runCmd('plutil', ['-convert', 'xml1', '-o', tmpXctestrunPath, tmpJsonPath], { + allowFailure: true, + }); + if (plistResult.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to write xctestrun plist', { + tmpXctestrunPath, + stderr: plistResult.stderr, + }); + } + + return { xctestrunPath: tmpXctestrunPath, jsonPath: tmpJsonPath }; +} + +function cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch { + // ignore + } +} diff --git a/src/utils/args.ts b/src/utils/args.ts new file mode 100644 index 000000000..c7787bfb5 --- /dev/null +++ b/src/utils/args.ts @@ -0,0 +1,102 @@ +import { AppError } from './errors.ts'; + +export type ParsedArgs = { + command: string | null; + positionals: string[]; + flags: { + json: boolean; + platform?: 'ios' | 'android'; + device?: string; + udid?: string; + serial?: string; + out?: string; + session?: string; + verbose?: boolean; + help: boolean; + }; +}; + +export function parseArgs(argv: string[]): ParsedArgs { + const flags: ParsedArgs['flags'] = { json: false, help: false }; + const positionals: string[] = []; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--json') { + flags.json = true; + continue; + } + if (arg === '--help' || arg === '-h') { + flags.help = true; + continue; + } + if (arg === '--verbose' || arg === '-v') { + flags.verbose = true; + continue; + } + if (arg.startsWith('--')) { + const [key, valueInline] = arg.split('='); + const value = valueInline ?? argv[i + 1]; + if (!valueInline) i += 1; + + switch (key) { + case '--platform': + if (value !== 'ios' && value !== 'android') { + throw new AppError('INVALID_ARGS', `Invalid platform: ${value}`); + } + flags.platform = value; + break; + case '--device': + flags.device = value; + break; + case '--udid': + flags.udid = value; + break; + case '--serial': + flags.serial = value; + break; + case '--out': + flags.out = value; + break; + case '--session': + flags.session = value; + break; + default: + throw new AppError('INVALID_ARGS', `Unknown flag: ${key}`); + } + continue; + } + positionals.push(arg); + } + + const command = positionals.shift() ?? null; + return { command, positionals, flags }; +} + +export function usage(): string { + return `agent-device [args] [--json] + +Commands: + open + close [app] + press + long-press [durationMs] + focus + type + fill + scroll [amount] + scrollintoview + screenshot [--out path] + session list + +Flags: + --platform ios|android + --device + --udid + --serial + --out + --session + --verbose + --json +`; +} diff --git a/src/utils/device.ts b/src/utils/device.ts new file mode 100644 index 000000000..1caf43238 --- /dev/null +++ b/src/utils/device.ts @@ -0,0 +1,84 @@ +import { AppError } from './errors.ts'; +import { isInteractive } from './interactive.ts'; +import { isCancel, select } from '@clack/prompts'; + +export type Platform = 'ios' | 'android'; +export type DeviceKind = 'simulator' | 'emulator' | 'device'; + +export type DeviceInfo = { + platform: Platform; + id: string; + name: string; + kind: DeviceKind; + booted?: boolean; +}; + +export type DeviceSelector = { + platform?: Platform; + deviceName?: string; + udid?: string; + serial?: string; +}; + +export async function selectDevice( + devices: DeviceInfo[], + selector: DeviceSelector, +): Promise { + let candidates = devices; + const normalize = (value: string): string => + value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); + + if (selector.platform) { + candidates = candidates.filter((d) => d.platform === selector.platform); + } + + if (selector.udid) { + const match = candidates.find((d) => d.id === selector.udid && d.platform === 'ios'); + if (!match) throw new AppError('DEVICE_NOT_FOUND', `No iOS device with UDID ${selector.udid}`); + return match; + } + + if (selector.serial) { + const match = candidates.find((d) => d.id === selector.serial && d.platform === 'android'); + if (!match) + throw new AppError('DEVICE_NOT_FOUND', `No Android device with serial ${selector.serial}`); + return match; + } + + if (selector.deviceName) { + const target = normalize(selector.deviceName); + const match = candidates.find((d) => normalize(d.name) === target); + if (!match) { + throw new AppError('DEVICE_NOT_FOUND', `No device named ${selector.deviceName}`); + } + return match; + } + + if (candidates.length === 1) return candidates[0]; + + if (candidates.length === 0) { + throw new AppError('DEVICE_NOT_FOUND', 'No devices found', { selector }); + } + + const booted = candidates.filter((d) => d.booted); + if (booted.length === 1) return booted[0]; + + if (isInteractive()) { + const choice = await select({ + message: 'Multiple devices available. Choose a device to continue:', + options: (booted.length > 0 ? booted : candidates).map((device) => ({ + label: `${device.name} (${device.platform}${device.kind ? `, ${device.kind}` : ''}${device.booted ? ', booted' : ''})`, + value: device.id, + })), + }); + if (isCancel(choice)) { + throw new AppError('INVALID_ARGS', 'Device selection cancelled'); + } + if (choice) { + const match = candidates.find((d) => d.id === choice); + if (match) return match; + } + } + + return booted[0] ?? candidates[0]; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 000000000..55ac207e2 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,35 @@ +export type ErrorCode = + | 'INVALID_ARGS' + | 'DEVICE_NOT_FOUND' + | 'TOOL_MISSING' + | 'APP_NOT_INSTALLED' + | 'UNSUPPORTED_PLATFORM' + | 'UNSUPPORTED_OPERATION' + | 'COMMAND_FAILED' + | 'UNKNOWN'; + +export class AppError extends Error { + code: ErrorCode; + details?: Record; + cause?: unknown; + + constructor( + code: ErrorCode, + message: string, + details?: Record, + cause?: unknown, + ) { + super(message); + this.code = code; + this.details = details; + this.cause = cause; + } +} + +export function asAppError(err: unknown): AppError { + if (err instanceof AppError) return err; + if (err instanceof Error) { + return new AppError('UNKNOWN', err.message, undefined, err); + } + return new AppError('UNKNOWN', 'Unknown error', { err }); +} diff --git a/src/utils/exec.ts b/src/utils/exec.ts new file mode 100644 index 000000000..cfc17d21c --- /dev/null +++ b/src/utils/exec.ts @@ -0,0 +1,229 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { AppError } from './errors.ts'; + +export type ExecResult = { + stdout: string; + stderr: string; + exitCode: number; + stdoutBuffer?: Buffer; +}; + +export type ExecOptions = { + cwd?: string; + env?: NodeJS.ProcessEnv; + allowFailure?: boolean; + binaryStdout?: boolean; +}; + +export type ExecStreamOptions = ExecOptions & { + onStdoutChunk?: (chunk: string) => void; + onStderrChunk?: (chunk: string) => void; +}; + +export async function runCmd( + cmd: string, + args: string[], + options: ExecOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stdoutBuffer: Buffer | undefined = options.binaryStdout ? Buffer.alloc(0) : undefined; + let stderr = ''; + + if (!options.binaryStdout) child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + + child.stdout.on('data', (chunk) => { + if (options.binaryStdout) { + stdoutBuffer = Buffer.concat([ + stdoutBuffer ?? Buffer.alloc(0), + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), + ]); + } else { + stdout += chunk; + } + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + + child.on('error', (err) => { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + reject(new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, err)); + return; + } + reject(new AppError('COMMAND_FAILED', `Failed to run ${cmd}`, { cmd, args }, err)); + }); + + child.on('close', (code) => { + const exitCode = code ?? 1; + if (exitCode !== 0 && !options.allowFailure) { + reject( + new AppError('COMMAND_FAILED', `${cmd} exited with code ${exitCode}`, { + cmd, + args, + stdout, + stderr, + exitCode, + }), + ); + return; + } + resolve({ stdout, stderr, exitCode, stdoutBuffer }); + }); + }); +} + +export async function whichCmd(cmd: string): Promise { + try { + const { shell, args } = resolveWhichArgs(cmd); + const result = await runCmd(shell, args, { allowFailure: true }); + return result.exitCode === 0 && result.stdout.trim().length > 0; + } catch { + return false; + } +} + +export function runCmdSync(cmd: string, args: string[], options: ExecOptions = {}): ExecResult { + const result = spawnSync(cmd, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: options.binaryStdout ? undefined : 'utf8', + }); + + if (result.error) { + const code = (result.error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + throw new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, result.error); + } + throw new AppError('COMMAND_FAILED', `Failed to run ${cmd}`, { cmd, args }, result.error); + } + + const stdoutBuffer = options.binaryStdout + ? Buffer.isBuffer(result.stdout) + ? result.stdout + : Buffer.from(result.stdout ?? '') + : undefined; + const stdout = options.binaryStdout + ? '' + : typeof result.stdout === 'string' + ? result.stdout + : (result.stdout ?? '').toString(); + const stderr = + typeof result.stderr === 'string' ? result.stderr : (result.stderr ?? '').toString(); + const exitCode = result.status ?? 1; + + if (exitCode !== 0 && !options.allowFailure) { + throw new AppError('COMMAND_FAILED', `${cmd} exited with code ${exitCode}`, { + cmd, + args, + stdout, + stderr, + exitCode, + }); + } + + return { stdout, stderr, exitCode, stdoutBuffer }; +} + +export function runCmdDetached(cmd: string, args: string[], options: ExecOptions = {}): void { + const child = spawn(cmd, args, { + cwd: options.cwd, + env: options.env, + stdio: 'ignore', + detached: true, + }); + child.unref(); +} + +export async function runCmdStreaming( + cmd: string, + args: string[], + options: ExecStreamOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let stdoutBuffer: Buffer | undefined = options.binaryStdout ? Buffer.alloc(0) : undefined; + + if (!options.binaryStdout) child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + + child.stdout.on('data', (chunk) => { + if (options.binaryStdout) { + stdoutBuffer = Buffer.concat([ + stdoutBuffer ?? Buffer.alloc(0), + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), + ]); + return; + } + const text = String(chunk); + stdout += text; + options.onStdoutChunk?.(text); + }); + + child.stderr.on('data', (chunk) => { + const text = String(chunk); + stderr += text; + options.onStderrChunk?.(text); + }); + + child.on('error', (err) => { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + reject(new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, err)); + return; + } + reject(new AppError('COMMAND_FAILED', `Failed to run ${cmd}`, { cmd, args }, err)); + }); + + child.on('close', (code) => { + const exitCode = code ?? 1; + if (exitCode !== 0 && !options.allowFailure) { + reject( + new AppError('COMMAND_FAILED', `${cmd} exited with code ${exitCode}`, { + cmd, + args, + stdout, + stderr, + exitCode, + }), + ); + return; + } + resolve({ stdout, stderr, exitCode, stdoutBuffer }); + }); + }); +} + +export function whichCmdSync(cmd: string): boolean { + try { + const { shell, args } = resolveWhichArgs(cmd); + const result = runCmdSync(shell, args, { allowFailure: true }); + return result.exitCode === 0 && result.stdout.trim().length > 0; + } catch { + return false; + } +} + +function resolveWhichArgs(cmd: string): { shell: string; args: string[] } { + if (process.platform === 'win32') { + return { shell: 'cmd.exe', args: ['/c', 'where', cmd] }; + } + return { shell: 'bash', args: ['-lc', `command -v ${cmd}`] }; +} diff --git a/src/utils/interactive.ts b/src/utils/interactive.ts new file mode 100644 index 000000000..a99cdae8d --- /dev/null +++ b/src/utils/interactive.ts @@ -0,0 +1,4 @@ +export function isInteractive(): boolean { + if (process.env.CI) return false; + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} diff --git a/src/utils/interactors.ts b/src/utils/interactors.ts new file mode 100644 index 000000000..1ff017625 --- /dev/null +++ b/src/utils/interactors.ts @@ -0,0 +1,72 @@ +import { AppError } from './errors.ts'; +import type { DeviceInfo } from './device.ts'; +import { + closeAndroidApp, + fillAndroid, + focusAndroid, + longPressAndroid, + openAndroidApp, + pressAndroid, + scrollAndroid, + scrollIntoViewAndroid, + screenshotAndroid, + typeAndroid, +} from '../platforms/android/index.ts'; +import { + closeIosApp, + fillIos, + focusIos, + longPressIos, + openIosApp, + pressIos, + scrollIos, + scrollIntoViewIos, + screenshotIos, + typeIos, +} from '../platforms/ios/index.ts'; + +export type Interactor = { + open(app: string): Promise; + close(app: string): Promise; + tap(x: number, y: number): Promise; + longPress(x: number, y: number, durationMs?: number): Promise; + focus(x: number, y: number): Promise; + type(text: string): Promise; + fill(x: number, y: number, text: string): Promise; + scroll(direction: string, amount?: number): Promise; + scrollIntoView(text: string): Promise; + screenshot(outPath: string): Promise; +}; + +export function getInteractor(device: DeviceInfo): Interactor { + switch (device.platform) { + case 'android': + return { + open: (app) => openAndroidApp(device, app), + close: (app) => closeAndroidApp(device, app), + tap: (x, y) => pressAndroid(device, x, y), + longPress: (x, y, durationMs) => longPressAndroid(device, x, y, durationMs), + focus: (x, y) => focusAndroid(device, x, y), + type: (text) => typeAndroid(device, text), + fill: (x, y, text) => fillAndroid(device, x, y, text), + scroll: (direction, amount) => scrollAndroid(device, direction, amount), + scrollIntoView: (text) => scrollIntoViewAndroid(device, text), + screenshot: (outPath) => screenshotAndroid(device, outPath), + }; + case 'ios': + return { + open: (app) => openIosApp(device, app), + close: (app) => closeIosApp(device, app), + tap: (x, y) => pressIos(device, x, y), + longPress: (x, y, durationMs) => longPressIos(device, x, y, durationMs), + focus: (x, y) => focusIos(device, x, y), + type: (text) => typeIos(device, text), + fill: (x, y, text) => fillIos(device, x, y, text), + scroll: (direction, amount) => scrollIos(device, direction, amount), + scrollIntoView: (text) => scrollIntoViewIos(text), + screenshot: (outPath) => screenshotIos(device, outPath), + }; + default: + throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${device.platform}`); + } +} diff --git a/src/utils/output.ts b/src/utils/output.ts new file mode 100644 index 000000000..55a5205ae --- /dev/null +++ b/src/utils/output.ts @@ -0,0 +1,14 @@ +import { AppError } from './errors.ts'; + +export type JsonResult = + | { success: true; data?: Record } + | { success: false; error: { code: string; message: string; details?: Record } }; + +export function printJson(result: JsonResult): void { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +export function printHumanError(err: AppError): void { + const details = err.details ? `\n${JSON.stringify(err.details, null, 2)}` : ''; + process.stderr.write(`Error (${err.code}): ${err.message}${details}\n`); +} diff --git a/test/integration/android-settings.test.ts b/test/integration/android-settings.test.ts new file mode 100644 index 000000000..201ba9762 --- /dev/null +++ b/test/integration/android-settings.test.ts @@ -0,0 +1,218 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runCmdSync, whichCmdSync } from '../../src/utils/exec.ts'; +import { existsSync } from 'node:fs'; + +function hasCommand(cmd: string): boolean { + return whichCmdSync(cmd); +} + +function runCli(args: string[]): { status: number; stdout: string; stderr: string } { + const result = runCmdSync( + process.execPath, + ['--experimental-strip-types', 'src/bin.ts', ...args], + { allowFailure: true }, + ); + return { status: result.exitCode, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +function runCliJson(args: string[]): { + status: number; + json?: any; + stdout: string; + stderr: string; +} { + const result = runCli(args); + let json: any; + try { + json = JSON.parse(result.stdout); + } catch { + json = undefined; + } + return { status: result.status, json, stdout: result.stdout, stderr: result.stderr }; +} + +test('android settings commands', { skip: shouldSkipAndroid() }, () => { + const selector = getAndroidSelectorArgs(); + const session = ['--session', 'android-test']; + const open = runCliJson([ + 'open', + 'Settings', + '--platform', + 'android', + '--json', + ...selector, + ...session, + ]); + assert.equal(open.status, 0, `${open.stderr}\n${open.stdout}`); + + const press = runCliJson([ + 'press', + '100', + '200', + '--platform', + 'android', + '--json', + ...selector, + ...session, + ]); + assert.equal(press.status, 0, `${press.stderr}\n${press.stdout}`); + + const longPress = runCliJson([ + 'long-press', + '100', + '200', + '800', + '--platform', + 'android', + '--json', + ...selector, + ...session, + ]); + assert.equal(longPress.status, 0, `${longPress.stderr}\n${longPress.stdout}`); + + const focus = runCliJson([ + 'focus', + '100', + '200', + '--platform', + 'android', + '--json', + ...selector, + ...session, + ]); + assert.equal(focus.status, 0, `${focus.stderr}\n${focus.stdout}`); + + const type = runCliJson([ + 'type', + 'agent-device', + '--platform', + 'android', + '--json', + ...selector, + ...session, + ]); + assert.equal(type.status, 0, `${type.stderr}\n${type.stdout}`); + + const fill = runCliJson([ + 'fill', + '100', + '200', + 'agent-device', + '--platform', + 'android', + '--json', + ...selector, + ...session, + ]); + assert.equal(fill.status, 0, `${fill.stderr}\n${fill.stdout}`); + + const scroll = runCliJson([ + 'scroll', + 'down', + '0.5', + '--platform', + 'android', + '--json', + ...selector, + ...session, + ]); + assert.equal(scroll.status, 0, `${scroll.stderr}\n${scroll.stdout}`); + + const scrollTarget = findAndroidSettingsLabel(); + if (scrollTarget) { + const scrollInto = runCliJson([ + 'scrollintoview', + scrollTarget, + '--platform', + 'android', + '--json', + ...selector, + ...session, + ]); + if (scrollInto.status === 0) { + assert.equal(scrollInto.status, 0, `${scrollInto.stderr}\n${scrollInto.stdout}`); + } else { + assert.equal( + scrollInto.json?.error?.code, + 'UNSUPPORTED_OPERATION', + `${scrollInto.stderr}\n${scrollInto.stdout}`, + ); + } + } + + const outPath = `./test/screenshots/android-settings.png`; + const shot = runCliJson([ + 'screenshot', + '--platform', + 'android', + '--json', + '--out', + outPath, + ...selector, + ...session, + ]); + assert.equal(shot.status, 0, `${shot.stderr}\n${shot.stdout}`); + assert.equal(existsSync(outPath), true); + + const close = runCliJson([ + 'close', + 'com.android.settings', + '--platform', + 'android', + '--json', + ...selector, + ...session, + ]); + assert.equal(close.status, 0, `${close.stderr}\n${close.stdout}`); +}); + +function shouldSkipAndroid(): boolean | string { + if (!hasCommand('adb')) return 'adb not available'; + const result = runCmdSync('adb', ['devices'], { allowFailure: true }); + if (result.exitCode !== 0) return 'adb devices failed'; + const devices = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('List of devices')); + if (devices.length === 0) return 'no android devices connected'; + return false; +} + +function getAndroidSelectorArgs(): string[] { + if (process.env.ANDROID_SERIAL) return ['--serial', process.env.ANDROID_SERIAL]; + if (process.env.ANDROID_DEVICE) return ['--device', process.env.ANDROID_DEVICE]; + return []; +} + +function findAndroidSettingsLabel(): string | null { + const hasSerial = Boolean(process.env.ANDROID_SERIAL); + if (!hasSerial && hasMultipleAndroidDevices()) return null; + const selector = hasSerial ? ['-s', process.env.ANDROID_SERIAL as string] : []; + const dump = runCmdSync( + 'adb', + [...selector, 'shell', 'uiautomator', 'dump', '/sdcard/window_dump.xml'], + { allowFailure: true }, + ); + if (dump.exitCode !== 0) return null; + const xml = runCmdSync('adb', [...selector, 'shell', 'cat', '/sdcard/window_dump.xml'], { + allowFailure: true, + }); + if (xml.exitCode !== 0) return null; + const content = xml.stdout ?? ''; + const candidates = ['About phone', 'System', 'Network & internet', 'Display', 'Battery']; + for (const label of candidates) { + if (content.includes(label)) return label; + } + return null; +} + +function hasMultipleAndroidDevices(): boolean { + const result = runCmdSync('adb', ['devices'], { allowFailure: true }); + if (result.exitCode !== 0) return true; + const devices = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('List of devices')); + return devices.length > 1; +} diff --git a/test/integration/ios-settings.test.ts b/test/integration/ios-settings.test.ts new file mode 100644 index 000000000..08ae6dd3a --- /dev/null +++ b/test/integration/ios-settings.test.ts @@ -0,0 +1,241 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runCmdSync, whichCmdSync } from '../../src/utils/exec.ts'; +import { existsSync } from 'node:fs'; + +function hasCommand(cmd: string): boolean { + return whichCmdSync(cmd); +} + +function runCli(args: string[]): { status: number; stdout: string; stderr: string } { + const result = runCmdSync( + process.execPath, + ['--experimental-strip-types', 'src/bin.ts', ...args], + { allowFailure: true }, + ); + return { status: result.exitCode, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +function runCliJson(args: string[]): { + status: number; + json?: any; + stdout: string; + stderr: string; +} { + const result = runCli(args); + let json: any; + try { + json = JSON.parse(result.stdout); + } catch { + json = undefined; + } + return { status: result.status, json, stdout: result.stdout, stderr: result.stderr }; +} + +test('ios settings commands', { skip: shouldSkipIos() }, () => { + const selector = getIosSelectorArgs(); + const session = ['--session', 'ios-test']; + const caps = getSimctlIoCaps(); + const open = runCliJson([ + 'open', + 'com.apple.Preferences', + '--platform', + 'ios', + '--json', + ...selector, + ...session, + ]); + assert.equal(open.status, 0, `${open.stderr}\n${open.stdout}`); + + const press = runCliJson([ + 'press', + '100', + '200', + '--platform', + 'ios', + '--json', + ...selector, + ...session, + ]); + assertSupportOrUnsupported(press, caps.tap, 'tap'); + + const longPress = runCliJson([ + 'long-press', + '100', + '200', + '800', + '--platform', + 'ios', + '--json', + ...selector, + ...session, + ]); + assertSupportOrUnsupported(longPress, caps.swipe, 'swipe'); + + const focus = runCliJson([ + 'focus', + '100', + '200', + '--platform', + 'ios', + '--json', + ...selector, + ...session, + ]); + assertSupportOrUnsupported(focus, caps.tap, 'tap'); + + const type = runCliJson([ + 'type', + 'agent-device', + '--platform', + 'ios', + '--json', + ...selector, + ...session, + ]); + assertSupportOrUnsupported(type, caps.keyboard, 'keyboard'); + + const fill = runCliJson([ + 'fill', + '100', + '200', + 'agent-device', + '--platform', + 'ios', + '--json', + ...selector, + ...session, + ]); + assertSupportOrUnsupported(fill, caps.tap && caps.keyboard, 'tap+keyboard'); + + const scroll = runCliJson([ + 'scroll', + 'down', + '0.5', + '--platform', + 'ios', + '--json', + ...selector, + ...session, + ]); + assertSupportOrUnsupported(scroll, caps.swipe, 'swipe'); + + const scrollInto = runCliJson([ + 'scrollintoview', + 'About', + '--platform', + 'ios', + '--json', + ...selector, + ...session, + ]); + assert.equal(scrollInto.status, 1); + assert.equal(scrollInto.json?.error?.code, 'UNSUPPORTED_OPERATION'); + + const outPath = `./test/screenshots/ios-settings.png`; + const shot = runCliJson([ + 'screenshot', + '--platform', + 'ios', + '--json', + '--out', + outPath, + ...selector, + ...session, + ]); + assert.equal(shot.status, 0, `${shot.stderr}\n${shot.stdout}`); + assert.equal(existsSync(outPath), true); + + const close = runCliJson([ + 'close', + 'com.apple.Preferences', + '--platform', + 'ios', + '--json', + ...selector, + ...session, + ]); + assert.equal(close.status, 0, `${close.stderr}\n${close.stdout}`); +}); + +function shouldSkipIos(): boolean | string { + if (process.platform !== 'darwin') return 'iOS tooling only available on macOS'; + if (!hasCommand('xcrun')) return 'xcrun not available'; + const result = runCmdSync('xcrun', ['simctl', 'list', 'devices', '-j'], { allowFailure: true }); + if (result.exitCode !== 0) return 'simctl list failed'; + if (!findPreferredSimulator()) return 'no available iOS simulator'; + return false; +} + +function getIosSelectorArgs(): string[] { + if (process.env.IOS_UDID) return ['--udid', process.env.IOS_UDID]; + if (process.env.IOS_DEVICE) return ['--device', process.env.IOS_DEVICE]; + const preferred = findPreferredSimulator(); + if (preferred?.udid) return ['--udid', preferred.udid]; + if (preferred?.name) return ['--device', preferred.name]; + return []; +} + +function getSimctlIoCaps(): { tap: boolean; swipe: boolean; keyboard: boolean } { + const result = runCmdSync('xcrun', ['simctl', 'io'], { allowFailure: true }); + const output = (result.stderr ?? '') as string; + const ops = extractIoOperations(output); + return { + tap: ops.has('tap'), + swipe: ops.has('swipe'), + keyboard: ops.has('keyboard'), + }; +} + +function extractIoOperations(text: string): Set { + const ops = new Set(); + const lines = text.split('\n'); + let inOps = false; + for (const line of lines) { + if (line.toLowerCase().includes('supported operations')) { + inOps = true; + continue; + } + if (!inOps) continue; + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('Example:')) break; + const op = trimmed.split(/\s+/)[0]; + if (op) ops.add(op); + } + return ops; +} + +function assertSupportOrUnsupported( + result: { status: number; json?: any; stdout: string; stderr: string }, + supported: boolean, + op: string, +): void { + if (supported) { + assert.equal(result.status, 0, `${op}\n${result.stderr}\n${result.stdout}`); + } else { + assert.equal(result.status, 1, `${op}\n${result.stderr}\n${result.stdout}`); + assert.equal(result.json?.error?.code, 'UNSUPPORTED_OPERATION'); + } +} + +function findPreferredSimulator(): { udid?: string; name?: string } | null { + const result = runCmdSync('xcrun', ['simctl', 'list', 'devices', '-j'], { allowFailure: true }); + if (result.exitCode !== 0) return null; + try { + const payload = JSON.parse(result.stdout) as { + devices: Record< + string, + { name: string; udid: string; state: string; isAvailable: boolean }[] + >; + }; + const all = Object.values(payload.devices ?? {}).flat(); + const booted = all.find((d) => d.isAvailable && d.state === 'Booted'); + if (booted) return { udid: booted.udid, name: booted.name }; + const named = all.find((d) => d.isAvailable && d.name === 'iPhone 17 Pro'); + if (named) return { udid: named.udid, name: named.name }; + } catch { + return null; + } + return null; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..d7aebaf24 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 000000000..505010cef --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": false, + "emitDeclarationOnly": false + }, + "include": ["src"] +}