diff --git a/.githooks/README.md b/.githooks/README.md new file mode 100644 index 0000000..ad26b93 --- /dev/null +++ b/.githooks/README.md @@ -0,0 +1,28 @@ +# .githooks — enforcement de versionado (Atenea) + +Hooks git-native (POSIX, sin dependencias). Corren para **cualquier** commit, hecho por una +persona o por un agente. Alinean el manual de versionado del equipo. + +- **`commit-msg`** — bloquea el commit si el mensaje no sigue Conventional Commits + (`()(!): descripción`). Avisa (no bloquea) si el tipo no cuadra con el prefijo + de la rama. +- **`pre-commit`** — bloquea commits directos sobre `main` o `beta` (el desarrollo va en ramas + temporales). Escape para promoción de release: `ATENEA_ALLOW_PROTECTED=1 git commit ...`. + +## Activación + +`core.hooksPath` es config local por clon (no se versiona), así que cada clon la activa una vez. +El proyecto lo automatiza con un script `prepare` en `package.json`: + +```json +"scripts": { "prepare": "git config core.hooksPath .githooks || true" } +``` + +Así, en cada `yarn install` queda cableado. Manual, si hiciera falta: + +```bash +git config core.hooksPath .githooks +chmod +x .githooks/* +``` + +Bypass puntual de un hook (no recomendado): `git commit --no-verify`. diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..7c28129 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,53 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────────────────── +# Atenea · commit-msg · valida Conventional Commits (manual de versionado Venteks) +# +# Git-native: corre para CUALQUIER commit (humano o agente), esté o no Claude. +# Bloquea el commit si el mensaje no cumple. Escape puntual: git commit --no-verify. +# +# Formato exigido: ()(!): +# tipos: feat fix docs style refactor perf test build ci chore revert +# ejemplos: feat(auth): agregar login por sesión +# fix: corregir cálculo de permisos +# feat(store)!: cambiar shape del estado (BREAKING CHANGE) +# ───────────────────────────────────────────────────────────────────────── + +msg_file="$1" +first_line="$(head -n 1 "$msg_file")" + +# Saltar commits de merge/revert automáticos y fixup/squash. +case "$first_line" in + "Merge "*|"Revert "*|"fixup!"*|"squash!"*) exit 0 ;; +esac + +pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?(!)?: .+' + +if printf '%s' "$first_line" | grep -Eq "$pattern"; then + # Aviso suave: coherencia tipo ↔ prefijo de rama (no bloquea). + # Una rama feat/* es un contenedor de feature: acumula fix/docs/refactor/chore/ + # test intermedios de forma legítima, así que NO se avisa sobre ella. Solo se + # revisan fix/* y hotfix/*, que son de propósito único. + branch="$(git symbolic-ref --short HEAD 2>/dev/null || echo '')" + type="$(printf '%s' "$first_line" | sed -E 's/^([a-z]+).*/\1/')" + case "$branch" in + fix/*) [ "$type" = "fix" ] || printf '⚠️ Atenea: rama fix/* pero commit "%s" (¿debería ser fix?)\n' "$type" >&2 ;; + hotfix/*) [ "$type" = "fix" ] || printf '⚠️ Atenea: rama hotfix/* pero commit "%s" (¿debería ser fix?)\n' "$type" >&2 ;; + esac + exit 0 +fi + +cat >&2 <<'EOF' +✕ Atenea · commit rechazado: el mensaje no sigue Conventional Commits. + + Formato: ()(!): + Tipos: feat fix docs style refactor perf test build ci chore revert + + Ejemplos válidos: + feat(auth): agregar login por sesión + fix: corregir cálculo de permisos + feat(store)!: cambiar shape del estado + + Manual de versionado del equipo (plugin Atenea, skill git-flow). + Bypass puntual (no recomendado): git commit --no-verify +EOF +exit 1 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..b0b0088 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,36 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────────────────── +# Atenea · pre-commit · protege ramas permanentes (manual de versionado Venteks) +# +# Regla del md: "nunca desarrolles directo sobre main". El desarrollo va en ramas +# temporales (feat/ fix/ chore/) desde beta; hotfix/ desde main. Este hook +# bloquea commits DIRECTOS sobre main o beta. +# +# Escape para promoción/merge de release: ATENEA_ALLOW_PROTECTED=1 git commit ... +# Bypass total puntual: git commit --no-verify +# ───────────────────────────────────────────────────────────────────────── + +[ "${ATENEA_ALLOW_PROTECTED:-}" = "1" ] && exit 0 + +branch="$(git symbolic-ref --short HEAD 2>/dev/null || echo '')" + +case "$branch" in + main|beta) + cat >&2 < · fix/ · chore/ (desde beta) + hotfix/ (desde main) + + Crea la rama y muévete a ella: + git switch -c feat/mi-cambio + + Promoción de release (merge a $branch): ATENEA_ALLOW_PROTECTED=1 git commit ... + Bypass puntual (no recomendado): git commit --no-verify +EOF + exit 1 + ;; +esac + +exit 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..99638cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + pull_request: + branches: [beta, main] + push: + branches: [beta, main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check: + name: Instalar y revisar sintaxis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run check diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad70397 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +Todas las notas relevantes de este proyecto se documentan aquí. +Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/); versionado [SemVer](https://semver.org/lang/es/). + +## [Sin liberar] + +### Añadido + +### Cambiado + +### Corregido + +### Eliminado + +## [1.0.0] - 2026-11-01 + +### Añadido +- Visor web del emulador: `screenrecord` H.264 por WebSocket, decodificado con WebCodecs. Guarda el GOP para quien se conecta después y manda una captura mientras la pantalla está quieta. +- Selector con todos los AVDs del equipo y los dispositivos de adb; un AVD apagado se arranca desde el visor. +- Controles como los del emulador: Atrás, Inicio, Recientes, Encendido, volumen, rotar izquierda/derecha, guardar captura a resolución completa y posturas de plegable. +- Posturas: en emuladores mueven la bisagra simulada (`emu posture`); en dispositivos físicos y Trasera, `cmd device_state state`. El stream sigue al display activo al plegar o rotar. +- API HTTP del visor (`/api/devices`, `/state`, `/screenshot`, `/tap`, `/swipe`, `/key`, `/text`, `/posture`, `/rotate`, `/launch`), solo desde orígenes locales. +- MCP por stdio (`list_devices`, `launch_avd`, `get_state`, `screenshot`, `tap`, `swipe`, `press_key`, `type_text`, `rotate`, `set_posture`): habla con el visor para que todo se vea en vivo, o con adb directo si el visor está apagado. +- Capturas en JavaScript puro: `screencap` en crudo, reducción por promedio de área y PNG con `zlib`, sin `sips` ni otras herramientas del sistema, así que funcionan en macOS, Linux y Windows. `GET /api/screenshot?max=` reduce la imagen y devuelve el tamaño real en `X-Screen-Width`/`X-Screen-Height`. +- Comando `android-viewer` para `npx @acertijosapps/android-viewer`: sin argumentos arranca el visor (`--port`, `--host`) y `mcp` arranca el MCP (`--viewer-url`); `--help` y `--version`. +- README en inglés con captura del visor (`media/`, incluida en el paquete y servida por unpkg), instalación con `npx` y configuración del MCP para Claude Code y otros clientes. +- Textos en inglés: interfaz del visor, errores de la API, herramientas del MCP y mensajes de la terminal. +- Git flow del proyecto: CHANGELOG, guía de contribución, hooks de git (Conventional Commits y ramas protegidas) y CI en GitHub Actions. + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0483807 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Contributing + +Contributions are welcome. Keep changes small, focused, and traceable. + +## Development workflow + +Use the Node.js version in [`.nvmrc`](./.nvmrc). Install dependencies from the root: + +```sh +npm install +``` + +`npm install` also wires the repository git hooks (`core.hooksPath` → `.githooks/`). + +You need the Android SDK (`adb` and `emulator`). The tools are looked up in `ANDROID_HOME`, `ANDROID_SDK_ROOT` or the default SDK location; override them with `ADB` and `EMULATOR`. + +### Commands + +```sh +npm start # start the viewer on http://localhost:8093 +npm run mcp # start the MCP server (stdio) +npm run check # syntax-check the sources +``` + +## Branching model + +Two permanent branches, both protected: + +- **`main`** — production. No direct pushes; changes land only through an approved Pull Request. +- **`beta`** — release candidate. + +Temporary branches, always created from `beta`, in kebab-case: + +- `feat/` · `fix/` · `chore/` → Pull Request **into `beta`**, deleted after merge. +- `hotfix/` is the only exception: created from `main`, PR into `main`, then back-merged into `beta`. + +One branch = one focused change. + +## Commits + +[Conventional Commits](https://www.conventionalcommits.org/): + +``` +()(!): +``` + +Types: `feat` `fix` `docs` `style` `refactor` `perf` `test` `build` `ci` `chore` `revert`. + +[SemVer](https://semver.org/) mapping: `feat` → MINOR, `fix` → PATCH, breaking (`feat!` or `BREAKING CHANGE:`) → MAJOR. + +The git hooks in [`.githooks/`](./.githooks) run for every commit: + +- `commit-msg` rejects messages that are not Conventional Commits. +- `pre-commit` blocks direct commits on `main` and `beta`. + +One-off bypass (not recommended): `git commit --no-verify`. + +## Changelog + +Any change that affects behavior updates [`CHANGELOG.md`](./CHANGELOG.md) ([Keep a Changelog](https://keepachangelog.com/)): add the entry under `## [Sin liberar]` in the right category (Añadido · Cambiado · Corregido · Eliminado). + +## Pull Requests + +When opening a PR: + +- Target `beta` (or `main` only for `hotfix`). +- Keep it small and focused on one change. +- Assign at least one reviewer; merge requires their approval. +- Link the related work item for traceability. +- Gates must be green: `npm run check`. CI ([`.github/workflows/ci.yml`](./.github/workflows/ci.yml)) runs it on every PR to `beta` or `main`. +- Include the `CHANGELOG.md` entry. + +## Releasing (`beta` → `main`) + +1. Confirm gates are green on `beta`. +2. Decide the SemVer bump from the commits since the last tag. +3. Update `version` in `package.json`. +4. Rename the `## [Sin liberar]` section of the changelog to `## [X.Y.Z] - YYYY-MM-DD` and open a fresh empty one. +5. Tag `vX.Y.Z` on `main`. diff --git a/README.md b/README.md index 1fe026e..f2b26bf 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,164 @@ -# Android Viewer +# @acertijosapps/android-viewer -Visor web del emulador de Android para el panel de navegador de Claude (Code tab), más un MCP para que Claude lo maneje. Lo que hace el MCP se ve en vivo en el visor. +Live Android emulator viewer for the browser, plus an MCP server so an AI agent can drive it. +Built for the browser pane of AI coding tools (such as Claude Code): you and the agent watch and +control the same emulator, in real time, including foldable postures. -``` -Claude ──MCP (stdio)──► mcp/server.js ──HTTP──► server.js (visor :8093) ──adb──► emulador - └──── adb directo si el visor está apagado ────┘ -Panel del navegador ◄──WebSocket (video + estado)── server.js +Android Viewer streaming a Pixel 10 Pro Fold emulator: AVD selector on top, the live screen in the middle, and the navigation, hardware, rotation, screenshot and posture controls at the bottom + +- **Live video**: `adb exec-out screenrecord` (H.264) over WebSocket, decoded in the browser with WebCodecs. +- **Input**: click, drag, long press and scroll become `adb shell input` commands. +- **Every AVD on your machine**: pick a running emulator or start a stopped one from the viewer. +- **Emulator controls**: Back, Home, Recents, Power, Volume, Rotate, full-resolution screenshot. +- **Foldables**: Closed / Half-opened / Opened / Rear display. On emulators they move the simulated hinge, like the emulator's own controls; the stream follows the active display when you fold or rotate. +- **MCP server**: the same actions as tools, so the agent's taps show up live in the viewer. +- **Nothing to install on the device**: no APK, no server push; only `adb`. + +## Requirements + +- Node.js 18 or later. +- Android SDK platform tools (`adb`) and, to list and start AVDs, the emulator. + Found through `ANDROID_HOME`, `ANDROID_SDK_ROOT` or the default SDK location; override with `ADB` and `EMULATOR`. +- A browser with WebCodecs (Chromium-based browsers, Safari 16.4+, Firefox 130+). +- Works on macOS, Linux and Windows. + +## Quick start + +```sh +npx @acertijosapps/android-viewer ``` -- **Video**: `adb exec-out screenrecord --output-format=h264 -` → WebSocket → WebCodecs (`VideoDecoder`). -- **Selector (arriba)**: todos los AVDs del equipo (`emulator -list-avds`) más los dispositivos de adb que no son AVD. Un AVD apagado muestra «Iniciar emulador»; al terminar de arrancar se conecta solo. -- **Input**: clic = `input tap`, arrastre/pulsación larga = `input swipe`, rueda = swipe corto. -- **Controles (abajo)**, como la barra del emulador: Atrás, Inicio, Recientes · Encendido, Bajar/Subir volumen · Rotar izquierda/derecha (`emu rotate`, solo emuladores) · Guardar captura (PNG a resolución completa) · Postura. -- **Postura**: botones con los estados de `cmd device_state print-states` (Cerrado / Medio / Abierto / Trasera) y «Auto». En emuladores, Cerrado/Medio/Abierto mueven la bisagra simulada con `emu posture` (Android deriva el estado, como los controles del emulador); Trasera y los dispositivos físicos usan `cmd device_state state`. Los cambios hechos desde fuera también se reflejan. +Open and pick a device. Deep links: `?avd=Pixel_10` or `?serial=emulator-5554`. -## Uso +```text +android-viewer [options] Start the viewer +android-viewer mcp [options] Start the MCP server on stdio -```bash -npm install && npm start + -p, --port Viewer port (default 8093, env PORT) + --host Interface to bind (default 127.0.0.1, env HOST) + --viewer-url Viewer the MCP talks to (default http://localhost:8093) ``` -Abre `http://localhost:8093` y elige el dispositivo en el selector (o `?avd=Pixel_10` / `?serial=emulator-5554`). +Other environment variables: `MAX_DIM` (longest side of the encoded video, default 1024), +`BIT_RATE` (default `6M`), `SCREENSHOT_MAX` (longest side of MCP screenshots, default 1024) and +`DEBUG=1` (logs every input command). + +## Use it with an AI agent (MCP) -Variables opcionales: `PORT` (8093), `MAX_DIM` (1024, lado mayor del video), `BIT_RATE` (6M), `ANDROID_HOME`, `ADB`, `EMULATOR`, `DEBUG=1` (registra cada comando de input). +Claude Code: -### En el panel de navegador de Claude Code +```sh +claude mcp add android-viewer -- npx -y @acertijosapps/android-viewer mcp +``` -Agrega a `.claude/launch.json` (no se versiona: lleva rutas de tu equipo) y abre con `preview_start android-viewer`: +Any other MCP client: ```json { - "version": "0.0.1", - "configurations": [ - { - "name": "android-viewer", - "runtimeExecutable": "/bin/bash", - "runtimeArgs": ["-c", "cd /ruta/a/android-viewer && exec node server.js"], - "port": 8093, - "autoPort": false + "mcpServers": { + "android-viewer": { + "command": "npx", + "args": ["-y", "@acertijosapps/android-viewer", "mcp"] } - ] + } } ``` -## MCP - -Registrado en `.mcp.json` como `android-viewer`. Claude Code pide habilitarlo la primera vez que se abre una sesión en esta carpeta. +Keep the viewer running (`npx @acertijosapps/android-viewer`) to watch what the agent does. Without +it, the MCP drives the device through `adb` directly. -| Herramienta | Qué hace | +| Tool | What it does | | --- | --- | -| `list_devices` | AVDs del equipo (serial y `booted` si corren) y dispositivos de adb | -| `launch_avd` | Arranca un AVD por nombre | -| `rotate` | Rota 90° a la izquierda o derecha (`emu rotate`) | -| `get_state` | Tamaño, rotación, postura y posturas disponibles; URL del visor | -| `screenshot` | Captura del display activo: `screencap` en crudo → BMP → JPEG con lado mayor ≤ 1024 (`sips`) | +| `list_devices` | AVDs on the machine (with `serial` and `booted` when running) and other adb devices | +| `launch_avd` | Start an AVD by name | +| `get_state` | Display size, rotation, current posture, available postures and the viewer URL | +| `screenshot` | PNG of the active display, longest side ≤ 1024 px | | `tap` | `input tap` | -| `swipe` | `input swipe`; mismo punto y ≥ 500 ms = pulsación larga | +| `swipe` | `input swipe`; same start and end with ≥ 500 ms is a long press | | `press_key` | back, home, recents, power, wakeup, volup, voldown, enter, delete, tab, escape | -| `type_text` | `input text` (solo ASCII) | -| `set_posture` | closed, half_opened, opened, rear_display, id o `reset`; en emuladores vía `emu posture`; devuelve el estado nuevo | - -Las coordenadas son normalizadas 0–1 sobre la captura (`x = px / ancho`), así sirven en cualquier postura. `serial` es opcional si hay un solo dispositivo. - -Variables: `ANDROID_VIEWER_URL` (http://localhost:8093), `SCREENSHOT_MAX` (1024). +| `type_text` | `input text` (printable ASCII) | +| `rotate` | Rotate 90° left or right (emulators) | +| `set_posture` | closed, half_opened, opened, rear_display, a state id, or `reset`; returns the new state | -Para probarlo a mano: `npm run mcp` (habla MCP por stdin/stdout). +Coordinates are normalized to `0–1` over the screenshot (`x = pixel_x / image_width`), so they hold +in any posture or orientation. `serial` is optional when only one device is connected. -## API HTTP del visor +### In the Claude Code browser pane -La usa el MCP; `serial` va en el query o en el cuerpo, y es opcional con un solo dispositivo. Los POST exigen `content-type: application/json` y un origen local. +Add the viewer to `.claude/launch.json` and open it with `preview_start android-viewer`: -- `GET /api/devices`, `GET /api/state`, `GET /api/screenshot` (PNG) -- `POST /api/tap {x, y}`, `/api/swipe {x1, y1, x2, y2, ms}`, `/api/key {key}`, `/api/text {text}`, `/api/posture {posture}`, `/api/rotate {direction}`, `/api/launch {avd}` - -`GET /api/devices` devuelve `[{ avd, serial, booted, model }]`: `serial` null = apagado; `booted` null = el emulador no respondió a tiempo. - -Con alguien mirando el visor, las acciones pasan por su sesión: el panel se actualiza al momento y el watchdog vigila el stream. - -## Cómo funciona - -- Una sesión por dispositivo, compartida entre pestañas; se detiene al cerrarse la última. -- Cada segundo lee `dumpsys display` (tamaño, rotación y display físico activo) y `cmd device_state state`. Si cambia el display, espera 1 s a que se asiente y reinicia `screenrecord` con `--display-id` del display físico nuevo: en un plegable, cerrar cambia de panel. -- `screenrecord` solo emite frames cuando la pantalla cambia. Por eso se guarda el GOP desde el último IDR para quien se conecte después, y se manda un `screencap` mientras no haya video. -- El último NAL se da por completo tras 80 ms sin datos (o antes, cuando llega el siguiente frame). Si llega una continuación, el umbral se duplica y se pide un IDR nuevo. -- Nunca corren dos `screenrecord` a la vez en un dispositivo: en el emulador agotan el encoder por software (`err=-38`). Los reinicios pedidos por el cliente están limitados a uno cada 2 s. - -## Limitaciones +```json +{ + "version": "0.0.1", + "configurations": [ + { + "name": "android-viewer", + "runtimeExecutable": "npx", + "runtimeArgs": ["-y", "@acertijosapps/android-viewer"], + "port": 8093, + "autoPort": false + } + ] +} +``` -- Los arrastres se envían al soltar (un `input swipe` con la duración real), no en vivo. -- No hay teclado ni audio. -- Con poca RAM libre (los emuladores paginados a swap) cada `adb shell` puede tardar decenas de segundos; el visor aguanta, pero todo va lento. Cierra emuladores o apps que no uses. -- Con varios emuladores codificando a la vez el frame rate baja: el encoder es por software. Con tres emuladores abiertos, cada consulta a `system_server` puede tardar 10–30 s; los timeouts de adb son de 30 s. -- `screencap -p` hace comprimir el PNG al emulador y con la CPU cargada tarda 14 s o más; por eso se pide en crudo (1–3 s). -- Flip_Horizontal_Fold tiene un solo display físico: `set_posture closed` cambia el estado, pero la imagen no cambia. -- Un gesto mandado justo después de cambiar de postura puede perderse mientras el launcher se reacomoda. -- Si una app pide su propia postura (`DeviceStateManager.requestState`), esa gana sobre `set_posture` mientras esté al frente. +## HTTP API -## Por qué no ws-scrcpy +The MCP uses the viewer's API; you can call it too. `serial` goes in the query string or the body +and is optional with a single device. `POST` requests require `content-type: application/json` and a +local origin, so other websites can't drive your devices. -Se probó primero [ws-scrcpy](https://github.com/NetrisTV/ws-scrcpy) (fork maxduke, v0.9.2): el stream y el input funcionan, pero al cambiar de postura el video se congela hasta recargar (su server scrcpy 1.19 queda atado al display anterior) y no tiene controles de postura. +| Endpoint | | +| --- | --- | +| `GET /api/devices` | `[{ avd, serial, booted, model }]`: `serial: null` = stopped, `booted: null` = didn't answer in time | +| `GET /api/state` | Display size, rotation, `displayId`, posture and available postures | +| `GET /api/screenshot?max=` | PNG; real screen size in `X-Screen-Width` / `X-Screen-Height` | +| `POST /api/tap` | `{ x, y }` | +| `POST /api/swipe` | `{ x1, y1, x2, y2, ms }` | +| `POST /api/key` | `{ key }` | +| `POST /api/text` | `{ text }` | +| `POST /api/posture` | `{ posture }` | +| `POST /api/rotate` | `{ direction: "left" \| "right" }` | +| `POST /api/launch` | `{ avd }` | + +When a viewer tab is watching the device, actions go through its live session: the viewer updates at +once and a watchdog restarts the stream if a gesture produces no frames. + +## How it works + +- One session per device, shared by every tab, stopped when the last one closes. +- Every second it reads `dumpsys display` (size, rotation and the physical display behind logical + display 0) and `cmd device_state state`. When the display changes, it waits for it to settle and + restarts `screenrecord` with `--display-id` of the new panel: folding a device switches panels. +- `screenrecord` only emits frames when the screen changes, so the viewer keeps the GOP since the last + IDR for late joiners and sends a screenshot while there is no video. +- Only one `screenrecord` runs per device at a time: two of them exhaust the emulator's software + encoder (`err=-38`). Restarts requested by clients are rate-limited. +- Screenshots use raw `screencap` (compressing PNG inside the emulator is slow under load), are scaled + down by area averaging and encoded as PNG with `node:zlib`, with no native dependencies. + +## Limitations + +- Drags are sent on release (one `input swipe` with the real duration), not live. +- No keyboard passthrough or audio yet; use `type_text` and `press_key`. +- Higher latency than a native client such as [scrcpy](https://github.com/Genymobile/scrcpy). +- The software encoder slows down with several emulators recording at once; with little free RAM + (emulators paged out to swap) every `adb shell` call can take seconds. +- If an app requests its own posture (`DeviceStateManager.requestState`), it wins over `set_posture` + while it is in the foreground. +- `Flip_Horizontal_Fold` style AVDs with a single physical display change state when closed, but the + picture stays the same. + +## Why not ws-scrcpy? + +[ws-scrcpy](https://github.com/NetrisTV/ws-scrcpy) was tried first: streaming and input work, but the +video freezes on a posture change until you reload (its scrcpy 1.19 server stays bound to the previous +display), and it has no posture controls. + +## Contributing + +Branching model (`main` · `beta`), Conventional Commits and releases are described in +[CONTRIBUTING.md](./CONTRIBUTING.md); changes are listed in [CHANGELOG.md](./CHANGELOG.md). ## Author diff --git a/bin/android-viewer.js b/bin/android-viewer.js new file mode 100755 index 0000000..e8f4e82 --- /dev/null +++ b/bin/android-viewer.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// CLI del paquete: `android-viewer` arranca el visor; `android-viewer mcp`, el MCP por stdio. + +import { readFileSync } from 'node:fs'; + +const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + +const HELP = `android-viewer ${version} + +Live Android emulator viewer for the browser, plus an MCP server to drive it. + +Usage: + android-viewer [options] Start the viewer (http://localhost:8093) + android-viewer mcp [options] Start the MCP server on stdio + +Viewer options: + -p, --port Port to listen on (default: 8093, env PORT) + --host Interface to bind (default: 127.0.0.1, env HOST) + +MCP options: + --viewer-url Viewer to talk to (default: http://localhost:8093, + env ANDROID_VIEWER_URL). Without a running viewer the + MCP drives the device through adb directly. + + -h, --help Show this help + -v, --version Show the version + +Android SDK tools are found through ANDROID_HOME, ANDROID_SDK_ROOT or the +default SDK location; override them with ADB and EMULATOR. +`; + +function fail(message) { + console.error(`android-viewer: ${message}\nRun "android-viewer --help" for usage.`); + process.exit(2); +} + +const args = process.argv.slice(2); +const command = args[0] && !args[0].startsWith('-') ? args.shift() : 'viewer'; +if (!['viewer', 'mcp'].includes(command)) fail(`unknown command "${command}"`); + +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const [flag, inline] = arg.includes('=') ? arg.split(/=(.*)/s) : [arg]; + const value = () => { + const v = inline ?? args[++i]; + if (v === undefined || v.startsWith('-')) fail(`${flag} needs a value`); + return v; + }; + switch (flag) { + case '-h': + case '--help': + process.stdout.write(HELP); + process.exit(0); + break; + case '-v': + case '--version': + console.log(version); + process.exit(0); + break; + case '-p': + case '--port': { + const port = value(); + if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) fail(`invalid port "${port}"`); + process.env.PORT = port; + break; + } + case '--host': + process.env.HOST = value(); + break; + case '--viewer-url': + process.env.ANDROID_VIEWER_URL = value(); + break; + default: + fail(`unknown option "${arg}"`); + } +} + +// Los módulos arrancan al importarse; las opciones ya quedaron en process.env. +await import(command === 'mcp' ? '../mcp/server.js' : '../server.js'); diff --git a/lib/device.js b/lib/device.js index 849695e..42109fb 100644 --- a/lib/device.js +++ b/lib/device.js @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { execFile, spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; +import { deflateSync } from 'node:zlib'; const SDK = process.env.ANDROID_HOME || @@ -72,13 +73,13 @@ export async function listDevices() { // Sin serial: el único dispositivo conectado; con varios hay que elegir. export async function resolveSerial(serial) { if (serial) { - if (!SERIAL_RE.test(serial)) throw invalid(`Serial inválido: ${serial}`); + if (!SERIAL_RE.test(serial)) throw invalid(`Invalid serial: ${serial}`); return serial; } const online = (await listDevices()).filter((d) => d.state === 'device'); if (online.length === 1) return online[0].serial; - if (!online.length) throw new Error('No hay dispositivos en adb.'); - throw invalid(`Hay varios dispositivos; indica serial: ${online.map((d) => d.serial).join(', ')}`); + if (!online.length) throw new Error('No adb devices connected.'); + throw invalid(`Several devices connected; pass a serial: ${online.map((d) => d.serial).join(', ')}`); } const booted = new Set(); @@ -132,7 +133,7 @@ export async function listTargets() { export async function launchAvd(avd) { const targets = await listTargets(); const target = targets.find((t) => t.avd === avd); - if (!target) throw invalid(`AVD desconocido: ${avd}. Disponibles: ${targets.filter((t) => t.avd).map((t) => t.avd).join(', ')}`); + if (!target) throw invalid(`Unknown AVD: ${avd}. Available: ${targets.filter((t) => t.avd).map((t) => t.avd).join(', ')}`); if (target.serial) return { avd, serial: target.serial, alreadyRunning: true }; const child = spawn(EMULATOR, ['-avd', avd], { detached: true, stdio: 'ignore' }); child.on('error', () => {}); @@ -142,8 +143,8 @@ export async function launchAvd(avd) { // `emu rotate` gira 90° en sentido horario, como el botón del emulador; a la izquierda son tres. export async function rotate(serial, direction) { - if (!serial.startsWith('emulator-')) throw invalid('Rotar solo está disponible en emuladores'); - if (direction !== 'left' && direction !== 'right') throw invalid('direction debe ser left o right'); + if (!serial.startsWith('emulator-')) throw invalid('Rotation is only available on emulators'); + if (direction !== 'left' && direction !== 'right') throw invalid('direction must be left or right'); for (let i = 0; i < (direction === 'right' ? 1 : 3); i++) await adb(serial, ['emu', 'rotate']); } @@ -154,7 +155,7 @@ export async function readDisplayState(serial) { ]); const [disp, ds = ''] = out.split('@@'); const size = disp.match(/real (\d+) x (\d+)/); - if (!size) throw new Error('No se pudo leer el tamaño del display'); + if (!size) throw new Error('Could not read the display size'); const rotation = disp.match(/\brotation (\d)/); const uid = disp.match(/uniqueId "local:(\d+)"/); const posture = ds.match(/identifier=(\d+), name='([^']+)'/); @@ -197,14 +198,14 @@ export function swipeCommand(state, x1, y1, x2, y2, ms) { export function keyCommand(key) { const code = KEYCODES[key]; - if (!code) throw invalid(`Tecla desconocida: ${key}. Válidas: ${Object.keys(KEYCODES).join(', ')}`); + if (!code) throw invalid(`Unknown key: ${key}. Valid keys: ${Object.keys(KEYCODES).join(', ')}`); return `input keyevent ${code}`; } export function textCommand(text) { const value = String(text ?? ''); - if (!value) throw invalid('Texto vacío'); - if (/[^\x20-\x7e]/.test(value)) throw invalid('`input text` solo admite ASCII imprimible'); + if (!value) throw invalid('Empty text'); + if (/[^\x20-\x7e]/.test(value)) throw invalid('`input text` only supports printable ASCII'); // `input text` toma %s como espacio; el resto va entre comillas simples para la shell. return `input text '${value.replace(/ /g, '%s').replace(/'/g, `'\\''`)}'`; } @@ -220,8 +221,8 @@ export function postureArg(states, value) { const found = states.find((s) => s.name === name || s.name === `${name}_MODE`); if (found) return found.id; } - const valid = states.map((s) => `${s.id}=${s.name}`).join(', ') || 'ninguna'; - throw invalid(`Postura no disponible: ${value}. Válidas: ${valid}, reset`); + const valid = states.map((s) => `${s.id}=${s.name}`).join(', ') || 'none'; + throw invalid(`Posture not available: ${value}. Valid: ${valid}, reset`); } // Posturas del sensor de bisagra del emulador (`emu posture`): mueven la bisagra simulada y @@ -239,21 +240,65 @@ export async function setPosture(serial, states, arg) { await adb(serial, ['shell', `cmd device_state state ${arg}`]); } -// Captura del display. Se pide en crudo (RGBA) y se convierte a BMP aquí: comprimir el PNG en el -// emulador es lo más lento con la CPU cargada (14 s contra ~1–6 s en crudo). Devuelve { data, mimeType }. -export async function screencap(serial, displayId) { +// Captura del display. Se pide en crudo (RGBA) y se codifica aquí: comprimir el PNG en el +// emulador es lo más lento con la CPU cargada (14 s contra ~1–6 s en crudo). Sin dependencias +// nativas ni herramientas del sistema, así que funciona igual en macOS, Linux y Windows. +// format: 'png' (por defecto) o 'bmp' (sin comprimir, lo más rápido para el navegador). +// maxDim: reduce la imagen para que su lado mayor no pase de ese tamaño. +// Devuelve { data, mimeType, width, height, screenWidth, screenHeight }. +export async function screencap(serial, displayId, { format = 'png', maxDim } = {}) { const display = displayId ? ` -d ${displayId}` : ''; const raw = await adb(serial, ['exec-out', `screencap${display}`], { encoding: 'buffer' }); - const width = raw.readUInt32LE(0); - const height = raw.readUInt32LE(4); - const format = raw.readUInt32LE(8); + const screenWidth = raw.readUInt32LE(0); + const screenHeight = raw.readUInt32LE(4); + const pixelFormat = raw.readUInt32LE(8); // Cabecera de 12 bytes (Android ≤ 11) o 16 (con dataspace); formato 1 = RGBA_8888, 2 = RGBX_8888. - const header = raw.length - width * height * 4; - if ((format === 1 || format === 2) && (header === 12 || header === 16)) { - return { data: rgbaToBmp(raw.subarray(header), width, height), mimeType: 'image/bmp' }; + const header = raw.length - screenWidth * screenHeight * 4; + if (!((pixelFormat === 1 || pixelFormat === 2) && (header === 12 || header === 16))) { + // Formato raro: PNG del dispositivo, sin reducir. + const png = await adb(serial, ['exec-out', `screencap -p${display}`], { encoding: 'buffer' }); + const size = imageSize(png); + return { data: png, mimeType: 'image/png', ...size, screenWidth: size.width, screenHeight: size.height }; } - const png = await adb(serial, ['exec-out', `screencap -p${display}`], { encoding: 'buffer' }); - return { data: png, mimeType: 'image/png' }; + let image = { width: screenWidth, height: screenHeight, rgba: raw.subarray(header) }; + if (maxDim && Math.max(screenWidth, screenHeight) > maxDim) image = downscale(image, maxDim); + const { width, height, rgba } = image; + return format === 'bmp' + ? { data: rgbaToBmp(rgba, width, height), mimeType: 'image/bmp', width, height, screenWidth, screenHeight } + : { data: rgbaToPng(rgba, width, height), mimeType: 'image/png', width, height, screenWidth, screenHeight }; +} + +// Reducción por promedio de área: cada píxel de salida promedia el bloque de origen que cubre. +function downscale({ width: sw, height: sh, rgba: src }, maxDim) { + const scale = maxDim / Math.max(sw, sh); + const width = Math.max(1, Math.round(sw * scale)); + const height = Math.max(1, Math.round(sh * scale)); + const dst = Buffer.alloc(width * height * 4); + for (let y = 0; y < height; y++) { + const y0 = Math.floor((y * sh) / height); + const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * sh) / height)); + for (let x = 0; x < width; x++) { + const x0 = Math.floor((x * sw) / width); + const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * sw) / width)); + let r = 0; + let g = 0; + let b = 0; + for (let yy = y0; yy < y1; yy++) { + for (let i = (yy * sw + x0) * 4, end = (yy * sw + x1) * 4; i < end; i += 4) { + r += src[i]; + g += src[i + 1]; + b += src[i + 2]; + } + } + const n = (y1 - y0) * (x1 - x0); + const o = (y * width + x) * 4; + dst[o] = r / n; + dst[o + 1] = g / n; + dst[o + 2] = b / n; + dst[o + 3] = 255; + } + } + return { width, height, rgba: dst }; } function rgbaToBmp(rgba, width, height) { @@ -280,8 +325,65 @@ function rgbaToBmp(rgba, width, height) { return bmp; } +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc32(buf) { + let c = 0xffffffff; + for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function pngChunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +// PNG RGB de 8 bits. Filtro Sub en cada fila: en capturas de UI deja casi todo en cero y +// deflate comprime mucho mejor. +function rgbaToPng(rgba, width, height) { + const row = width * 3 + 1; + const raw = Buffer.alloc(row * height); + for (let y = 0; y < height; y++) { + let o = y * row; + raw[o++] = 1; + let pr = 0; + let pg = 0; + let pb = 0; + for (let x = 0, i = y * width * 4; x < width; x++, i += 4) { + const r = rgba[i]; + const g = rgba[i + 1]; + const b = rgba[i + 2]; + raw[o++] = (r - pr) & 0xff; + raw[o++] = (g - pg) & 0xff; + raw[o++] = (b - pb) & 0xff; + pr = r; + pg = g; + pb = b; + } + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bits por canal + ihdr[9] = 2; // RGB + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk('IHDR', ihdr), + pngChunk('IDAT', deflateSync(raw, { level: 6 })), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + // Ancho y alto de un PNG o BMP. -export function imageSize(data) { +function imageSize(data) { if (data[0] === 0x42 && data[1] === 0x4d) return { width: data.readInt32LE(18), height: Math.abs(data.readInt32LE(22)) }; return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) }; } @@ -334,6 +436,7 @@ export function createActions({ cachedState = () => null, afterInput = () => {}, await afterPosture(serial, next); return next; }, - screenshot: async ({ serial }) => screencap(serial, (await state(serial)).displayId), + screenshot: async ({ serial, max }) => + screencap(serial, (await state(serial)).displayId, { maxDim: Number(max) || undefined }), }; } diff --git a/mcp/server.js b/mcp/server.js index 2cfd65b..8d1ba6e 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -3,14 +3,11 @@ // si el visor no está corriendo, usa adb directo con la misma lógica (lib/device.js). // stdout es del protocolo MCP: los logs van a stderr. -import os from 'node:os'; -import path from 'node:path'; -import { execFile } from 'node:child_process'; -import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; -import { KEYCODES, createActions, imageSize, resolveSerial } from '../lib/device.js'; +import { KEYCODES, createActions, resolveSerial } from '../lib/device.js'; const VIEWER_URL = (process.env.ANDROID_VIEWER_URL || 'http://localhost:8093').replace(/\/$/, ''); const SCREENSHOT_MAX = Number(process.env.SCREENSHOT_MAX) || 1024; @@ -34,7 +31,7 @@ async function viewer(method, route, params = {}) { try { res = await fetch(url, init); } catch (err) { - if (err.name === 'TimeoutError') throw new Error(`El visor no respondió en ${VIEWER_TIMEOUT_MS / 1000} s`); + if (err.name === 'TimeoutError') throw new Error(`The viewer did not answer within ${VIEWER_TIMEOUT_MS / 1000} s`); throw new ViewerDown(err.message); } if (!res.ok) { @@ -42,7 +39,13 @@ async function viewer(method, route, params = {}) { throw new Error(body.error || `HTTP ${res.status}`); } const type = res.headers.get('content-type') || ''; - return type.startsWith('image/') ? { data: Buffer.from(await res.arrayBuffer()), mimeType: type } : res.json(); + if (!type.startsWith('image/')) return res.json(); + return { + data: Buffer.from(await res.arrayBuffer()), + mimeType: type, + screenWidth: Number(res.headers.get('x-screen-width')), + screenHeight: Number(res.headers.get('x-screen-height')), + }; } // Ejecuta una acción por el visor o, si no está corriendo, por adb. @@ -56,46 +59,24 @@ async function run(action, method, route, params) { } } -function execFileAsync(cmd, args) { - return new Promise((resolve, reject) => - execFile(cmd, args, (err, stdout, stderr) => (err ? reject(new Error(String(stderr) || err.message)) : resolve(stdout))), - ); -} - -// Reduce la captura con `sips` (macOS) para no mandar varios MB al modelo. -async function shrink({ data: image, mimeType }) { - const dir = await mkdtemp(path.join(os.tmpdir(), 'android-viewer-')); - try { - const src = path.join(dir, mimeType === 'image/bmp' ? 'in.bmp' : 'in.png'); - const out = path.join(dir, 'out.jpg'); - await writeFile(src, image); - await execFileAsync('sips', ['-Z', String(SCREENSHOT_MAX), '-s', 'format', 'jpeg', '-s', 'formatOptions', '80', src, '--out', out]); - return { data: await readFile(out), mimeType: 'image/jpeg' }; - } catch (err) { - console.error('sips falló; se manda la captura original:', err.message); - return { data: image, mimeType }; - } finally { - await rm(dir, { recursive: true, force: true }); - } -} - const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] }); const serialArg = z .string() .optional() - .describe('Serial de adb (p. ej. emulator-5556). Opcional si hay un solo dispositivo conectado.'); -const coord = (what) => z.number().min(0).max(1).describe(`${what}, normalizada 0–1 sobre la captura de pantalla`); + .describe('adb serial (e.g. emulator-5556). Optional when only one device is connected.'); +const coord = (what) => z.number().min(0).max(1).describe(`${what}, normalized 0–1 over the screenshot`); -const server = new McpServer({ name: 'android-viewer', version: '0.1.0' }); +const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); +const server = new McpServer({ name: 'android-viewer', version }); server.registerTool( 'list_devices', { - title: 'Listar dispositivos', + title: 'List devices', description: - 'Lista los AVDs del equipo (con serial y booted si el emulador está corriendo) y los ' + - 'dispositivos de adb que no son AVD. Un AVD con serial null está apagado: arráncalo con launch_avd.', + 'Lists the AVDs on this machine (with serial and booted when the emulator is running) and the ' + + 'adb devices that are not AVDs. An AVD with serial null is stopped: start it with launch_avd.', annotations: { readOnlyHint: true }, }, async () => text((await run('devices', 'GET', '/api/devices', {})).result), @@ -104,10 +85,10 @@ server.registerTool( server.registerTool( 'get_state', { - title: 'Estado del dispositivo', + title: 'Device state', description: - 'Tamaño y rotación del display activo, postura actual y posturas disponibles (plegables). ' + - 'Incluye la URL del visor para verlo en el panel del navegador.', + 'Size and rotation of the active display, current posture and available postures (foldables). ' + + 'Includes the viewer URL to watch the device in a browser pane.', inputSchema: { serial: serialArg }, annotations: { readOnlyHint: true }, }, @@ -115,7 +96,7 @@ server.registerTool( const { result, via } = await run('state', 'GET', '/api/state', { serial }); return text({ ...result, - viewer: via === 'visor' ? `${VIEWER_URL}/?serial=${result.serial}` : 'apagado (inícialo con `npm start` o preview_start android-view)', + viewer: via === 'visor' ? `${VIEWER_URL}/?serial=${result.serial}` : 'not running (start it with `npx @acertijosapps/android-viewer`)', }); }, ); @@ -123,21 +104,19 @@ server.registerTool( server.registerTool( 'screenshot', { - title: 'Captura de pantalla', + title: 'Screenshot', description: - 'Captura la pantalla del display activo. Para tap/swipe usa coordenadas normalizadas: ' + - 'x = píxel_x / ancho de la imagen, y = píxel_y / alto de la imagen.', + 'Captures the active display (PNG). For tap/swipe use normalized coordinates: ' + + 'x = pixel_x / image width, y = pixel_y / image height.', inputSchema: { serial: serialArg }, annotations: { readOnlyHint: true }, }, async ({ serial }) => { - const { result: capture } = await run('screenshot', 'GET', '/api/screenshot', { serial }); - const { width, height } = imageSize(capture.data); - const { data, mimeType } = await shrink(capture); + const { result: shot } = await run('screenshot', 'GET', '/api/screenshot', { serial, max: SCREENSHOT_MAX }); return { content: [ - { type: 'image', data: data.toString('base64'), mimeType }, - { type: 'text', text: `Pantalla de ${width}×${height} px (imagen reducida a lado mayor ≤ ${SCREENSHOT_MAX}).` }, + { type: 'image', data: shot.data.toString('base64'), mimeType: shot.mimeType }, + { type: 'text', text: `Screen is ${shot.screenWidth}×${shot.screenHeight} px (image scaled to a longest side ≤ ${SCREENSHOT_MAX}).` }, ], }; }, @@ -146,72 +125,72 @@ server.registerTool( server.registerTool( 'tap', { - title: 'Tocar', - description: 'Toca un punto de la pantalla (input tap).', - inputSchema: { x: coord('Coordenada x'), y: coord('Coordenada y'), serial: serialArg }, + title: 'Tap', + description: 'Taps a point on the screen (input tap).', + inputSchema: { x: coord('x coordinate'), y: coord('y coordinate'), serial: serialArg }, }, async ({ x, y, serial }) => { await run('tap', 'POST', '/api/tap', { serial, x, y }); - return text(`tap en (${x.toFixed(3)}, ${y.toFixed(3)})`); + return text(`tapped (${x.toFixed(3)}, ${y.toFixed(3)})`); }, ); server.registerTool( 'swipe', { - title: 'Deslizar', + title: 'Swipe', description: - 'Desliza de un punto a otro (input swipe). Con el mismo punto de inicio y fin y una ' + - 'duración ≥ 500 ms es una pulsación larga.', + 'Swipes from one point to another (input swipe). With the same start and end point and a ' + + 'duration ≥ 500 ms it is a long press.', inputSchema: { - x1: coord('x inicial'), - y1: coord('y inicial'), - x2: coord('x final'), - y2: coord('y final'), - duration_ms: z.number().int().min(50).max(5000).default(300).describe('Duración del gesto en ms'), + x1: coord('Start x'), + y1: coord('Start y'), + x2: coord('End x'), + y2: coord('End y'), + duration_ms: z.number().int().min(50).max(5000).default(300).describe('Gesture duration in ms'), serial: serialArg, }, }, async ({ x1, y1, x2, y2, duration_ms, serial }) => { await run('swipe', 'POST', '/api/swipe', { serial, x1, y1, x2, y2, ms: duration_ms }); - return text(`swipe (${x1.toFixed(3)}, ${y1.toFixed(3)}) → (${x2.toFixed(3)}, ${y2.toFixed(3)}) en ${duration_ms} ms`); + return text(`swipe (${x1.toFixed(3)}, ${y1.toFixed(3)}) → (${x2.toFixed(3)}, ${y2.toFixed(3)}) in ${duration_ms} ms`); }, ); server.registerTool( 'press_key', { - title: 'Pulsar tecla', - description: 'Pulsa una tecla del sistema (input keyevent).', - inputSchema: { key: z.enum(Object.keys(KEYCODES)).describe('Tecla'), serial: serialArg }, + title: 'Press key', + description: 'Presses a system key (input keyevent).', + inputSchema: { key: z.enum(Object.keys(KEYCODES)).describe('Key'), serial: serialArg }, }, async ({ key, serial }) => { await run('key', 'POST', '/api/key', { serial, key }); - return text(`tecla ${key}`); + return text(`pressed ${key}`); }, ); server.registerTool( 'type_text', { - title: 'Escribir texto', - description: 'Escribe texto ASCII en el campo enfocado (input text). Para Enter usa press_key.', - inputSchema: { text: z.string().min(1).max(1000).describe('Texto a escribir'), serial: serialArg }, + title: 'Type text', + description: 'Types ASCII text into the focused field (input text). For Enter use press_key.', + inputSchema: { text: z.string().min(1).max(1000).describe('Text to type'), serial: serialArg }, }, async ({ text: value, serial }) => { await run('text', 'POST', '/api/text', { serial, text: value }); - return text(`escrito: ${JSON.stringify(value)}`); + return text(`typed ${JSON.stringify(value)}`); }, ); server.registerTool( 'launch_avd', { - title: 'Iniciar AVD', + title: 'Launch AVD', description: - 'Arranca un emulador por nombre de AVD (ver list_devices). Vuelve enseguida: espera a que ' + - 'list_devices lo muestre con booted=true antes de usarlo.', - inputSchema: { avd: z.string().describe('Nombre del AVD, p. ej. Pixel_10_Pro_Fold') }, + 'Starts an emulator by AVD name (see list_devices). Returns right away: wait until ' + + 'list_devices shows it with booted=true before using it.', + inputSchema: { avd: z.string().describe('AVD name, e.g. Pixel_10_Pro_Fold') }, }, async ({ avd }) => text((await run('launch', 'POST', '/api/launch', { avd })).result), ); @@ -219,27 +198,27 @@ server.registerTool( server.registerTool( 'rotate', { - title: 'Rotar', - description: 'Rota el emulador 90° (como el botón de rotar del emulador). Solo emuladores.', - inputSchema: { direction: z.enum(['left', 'right']).describe('Sentido'), serial: serialArg }, + title: 'Rotate', + description: "Rotates the emulator 90° (like the emulator's rotate button). Emulators only.", + inputSchema: { direction: z.enum(['left', 'right']).describe('Direction'), serial: serialArg }, }, async ({ direction, serial }) => { await run('rotate', 'POST', '/api/rotate', { serial, direction }); - return text(`rotado a la ${direction === 'left' ? 'izquierda' : 'derecha'}`); + return text(`rotated ${direction}`); }, ); server.registerTool( 'set_posture', { - title: 'Cambiar postura', + title: 'Set posture', description: - 'Cambia la postura de un plegable. En emuladores mueve la bisagra simulada (`emu posture`); ' + - 'en dispositivos físicos o REAR_DISPLAY usa `cmd device_state state`. Acepta el nombre ' + - '(closed, half_opened, opened, rear_display), el id numérico o "reset" para volver a la del sensor. ' + - 'Devuelve el estado nuevo.', + 'Changes the posture of a foldable. On emulators it moves the simulated hinge (`emu posture`); ' + + 'on physical devices or for REAR_DISPLAY it uses `cmd device_state state`. Accepts the name ' + + '(closed, half_opened, opened, rear_display), the numeric id or "reset" to go back to the sensor posture. ' + + 'Returns the new state.', inputSchema: { - posture: z.union([z.string(), z.number().int()]).describe('Nombre, id o "reset"'), + posture: z.union([z.string(), z.number().int()]).describe('Name, id or "reset"'), serial: serialArg, }, }, @@ -247,4 +226,4 @@ server.registerTool( ); await server.connect(new StdioServerTransport()); -console.error(`android-viewer MCP listo (visor: ${VIEWER_URL})`); +console.error(`android-viewer MCP ready (viewer: ${VIEWER_URL})`); diff --git a/media/viewer-foldable.jpg b/media/viewer-foldable.jpg new file mode 100644 index 0000000..bf4f863 Binary files /dev/null and b/media/viewer-foldable.jpg differ diff --git a/package-lock.json b/package-lock.json index f720c84..02c5432 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,22 @@ { "name": "@acertijosapps/android-viewer", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@acertijosapps/android-viewer", - "version": "0.1.0", + "version": "1.0.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.32.1", "ws": "^8.18.0", "zod": "4.6.7" }, + "bin": { + "android-viewer": "bin/android-viewer.js" + }, "engines": { "node": ">=18" } diff --git a/package.json b/package.json index dcd24c1..6513ffe 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,27 @@ { "name": "@acertijosapps/android-viewer", - "version": "0.1.0", + "version": "1.0.0", "description": "Visor web del emulador de Android (screenrecord H.264 + WebCodecs, input por adb, posturas de plegable) y MCP para que un agente lo maneje.", "type": "module", "main": "server.js", + "bin": { + "android-viewer": "bin/android-viewer.js" + }, "files": [ + "bin", "server.js", "lib", "mcp", "public", + "media", "README.md", "LICENSE" ], "scripts": { - "start": "node server.js", - "mcp": "node mcp/server.js" + "start": "node bin/android-viewer.js", + "mcp": "node bin/android-viewer.js mcp", + "check": "node --check bin/android-viewer.js && node --check server.js && node --check lib/device.js && node --check mcp/server.js && node --check public/viewer.js", + "postinstall": "git config core.hooksPath .githooks || exit 0" }, "keywords": [ "android", diff --git a/public/index.html b/public/index.html index 190cf1e..137fc00 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,5 @@ - + @@ -8,53 +8,53 @@
- +
-

Conectando…

- +

Connecting…

+
-
- - -
-
- - -
-
- -
-
-
- +
diff --git a/public/viewer.js b/public/viewer.js index edcf67e..d52d16b 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -9,7 +9,7 @@ const placeholderText = $('#placeholder-text'); const launchButton = $('#launch'); const controls = $('.controls'); -const POSTURE_LABELS = { CLOSED: 'Cerrado', HALF_OPENED: 'Medio', OPENED: 'Abierto', REAR_DISPLAY_MODE: 'Trasera' }; +const POSTURE_LABELS = { CLOSED: 'Closed', HALF_OPENED: 'Half open', OPENED: 'Open', REAR_DISPLAY_MODE: 'Rear display' }; const TAP_SLOP_PX = 20; // en píxeles del dispositivo const LONG_PRESS_MS = 500; const TARGETS_REFRESH_MS = 4000; @@ -92,7 +92,7 @@ function onFrame(buf) { const flags = buf[0]; const data = buf.subarray(1); if (flags & 0x80) { - drawSnapshot(data).catch((err) => console.warn('captura:', err)); + drawSnapshot(data).catch((err) => console.warn('screenshot:', err)); return; } const isKey = (flags & 1) !== 0; @@ -152,7 +152,7 @@ function renderPostures() { ); const reset = document.createElement('button'); reset.textContent = 'Auto'; - reset.title = 'Quita la postura forzada (cmd device_state state reset)'; + reset.title = 'Clear the forced posture (cmd device_state state reset)'; reset.onclick = () => send({ type: 'posture', id: 'reset' }); postureGroup.append(reset); } @@ -177,8 +177,8 @@ const selected = () => targets.find((t) => keyOf(t) === selectedKey); function targetLabel(t) { const name = t.avd || t.model || t.serial; - if (!t.serial) return `${name} · apagado`; - return t.booted === false ? `${name} · arrancando…` : `${name} · ${t.serial}`; + if (!t.serial) return `${name} · stopped`; + return t.booted === false ? `${name} · booting…` : `${name} · ${t.serial}`; } function showMessage(text, { launch = false } = {}) { @@ -208,14 +208,14 @@ function disconnect() { function connect(nextSerial) { disconnect(); serial = nextSerial; - showMessage('Conectando…'); + showMessage('Connecting…'); setControlsEnabled(true); const socket = new WebSocket(`ws://${location.host}/ws?serial=${encodeURIComponent(nextSerial)}`); socket.binaryType = 'arraybuffer'; socket.onmessage = (e) => (typeof e.data === 'string' ? onJson(JSON.parse(e.data)) : onFrame(new Uint8Array(e.data))); socket.onclose = () => { if (ws !== socket) return; - renderStatus('Desconectado; reintentando…'); + renderStatus('Disconnected; retrying…'); // Si el emulador se cerró, refreshTargets() muestra «apagado» en vez de reintentar. setTimeout(() => ws === socket && refreshTargets(true), 1500); }; @@ -236,21 +236,21 @@ function applySelection(force = false) { } if (serial) disconnect(); renderStatus(''); - if (t.serial || launching.has(t.avd)) showMessage(`${t.avd || t.serial} está arrancando…`); - else showMessage(`${t.avd} está apagado.`, { launch: true }); + if (t.serial || launching.has(t.avd)) showMessage(`${t.avd || t.serial} is booting…`); + else showMessage(`${t.avd} is stopped.`, { launch: true }); } async function refreshTargets(reconnect = false) { try { targets = await (await fetch('api/devices')).json(); } catch { - renderStatus('El visor no responde'); + renderStatus('The viewer is not responding'); return; } if (!targets.length) { deviceSelect.replaceChildren(); disconnect(); - showMessage('No hay AVDs ni dispositivos en adb.'); + showMessage('No AVDs or adb devices found.'); return; } if (!selected()) { @@ -279,7 +279,7 @@ deviceSelect.onchange = () => { launchButton.onclick = async () => { const t = selected(); launchButton.disabled = true; - placeholderText.textContent = `Iniciando ${t.avd}…`; + placeholderText.textContent = `Starting ${t.avd}…`; try { const res = await fetch('api/launch', { method: 'POST', @@ -288,9 +288,9 @@ launchButton.onclick = async () => { }); if (!res.ok) throw new Error((await res.json()).error); launching.add(t.avd); - showMessage(`${t.avd} está arrancando…`); + showMessage(`${t.avd} is booting…`); } catch (err) { - showMessage(`No se pudo iniciar ${t.avd}: ${err.message}`, { launch: true }); + showMessage(`Could not start ${t.avd}: ${err.message}`, { launch: true }); } }; @@ -301,29 +301,22 @@ setInterval(() => refreshTargets(), TARGETS_REFRESH_MS); document.querySelectorAll('[data-key]').forEach((b) => (b.onclick = () => send({ type: 'key', key: b.dataset.key }))); document.querySelectorAll('[data-rotate]').forEach((b) => (b.onclick = () => send({ type: 'rotate', direction: b.dataset.rotate }))); -// Captura a resolución completa (la del canvas es la del video, reducida). +// Captura a resolución completa (la del canvas es la del video, reducida); el visor ya la da en PNG. $('#screenshot').onclick = async () => { if (!serial) return; const t = selected(); - renderStatus('Guardando captura…'); + renderStatus('Saving screenshot…'); try { const res = await fetch(`api/screenshot?serial=${encodeURIComponent(serial)}`); if (!res.ok) throw new Error((await res.json()).error); - const bitmap = await createImageBitmap(await res.blob()); - const out = document.createElement('canvas'); - out.width = bitmap.width; - out.height = bitmap.height; - out.getContext('2d').drawImage(bitmap, 0, 0); - bitmap.close(); - const png = await new Promise((resolve) => out.toBlob(resolve, 'image/png')); const a = document.createElement('a'); - a.href = URL.createObjectURL(png); + a.href = URL.createObjectURL(await res.blob()); a.download = `${t?.avd || serial}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.png`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); renderStatus(); } catch (err) { - renderStatus(`Captura falló: ${err.message}`); + renderStatus(`Screenshot failed: ${err.message}`); } }; @@ -389,7 +382,7 @@ canvas.addEventListener( ); if (!('VideoDecoder' in window)) { - showMessage('Este navegador no tiene WebCodecs (VideoDecoder).'); + showMessage('This browser does not support WebCodecs (VideoDecoder).'); } else { setControlsEnabled(false); refreshTargets(); diff --git a/server.js b/server.js index 79bb7d0..e841fe2 100644 --- a/server.js +++ b/server.js @@ -165,11 +165,11 @@ class DeviceSession { this.state = await readDisplayState(this.serial); if (this.disposed) return; this.pollTimer = setInterval(() => this.poll(), POLL_MS); - log(this.serial, 'sesión', JSON.stringify(this.state), 'posturas', this.states.map((s) => s.name).join(',')); + log(this.serial, 'session', JSON.stringify(this.state), 'postures', this.states.map((s) => s.name).join(',')); } dispose() { - log(this.serial, 'sin clientes; se detiene'); + log(this.serial, 'no viewers left; stopping'); this.disposed = true; clearInterval(this.pollTimer); clearTimeout(this.restartTimer); @@ -195,7 +195,7 @@ class DeviceSession { // más nuevo (p. ej. tras set_posture), este resultado ya es viejo. if (version === this.stateVersion) this.applyState(next); } catch (err) { - log(this.serial, 'poll falló:', err.message); + log(this.serial, 'poll failed:', err.message); } finally { this.polling = false; } @@ -214,7 +214,7 @@ class DeviceSession { next.rotation !== prev.rotation || next.displayId !== prev.displayId; if (displayChanged || next.posture?.id !== prev.posture?.id) { - log(this.serial, 'cambio', JSON.stringify(next)); + log(this.serial, 'changed', JSON.stringify(next)); this.broadcastJson(this.meta()); } if (displayChanged) this.restart(DISPLAY_SETTLE_MS); @@ -278,7 +278,7 @@ class DeviceSession { const splitter = new AnnexBSplitter( (nal) => this.recorder === proc && this.onNal(nal, proc), (tailMs) => { - log(this.serial, `NAL cortado antes de tiempo; umbral ahora ${tailMs} ms`); + log(this.serial, `NAL flushed too early; threshold now ${tailMs} ms`); this.requestKeyframe(); }, ); @@ -293,7 +293,7 @@ class DeviceSession { // Fin normal por límite de tiempo → reinicio inmediato; si muere al arrancar, con espera. const ranMs = Date.now() - proc.startedAt; this.backoff = ranMs > 3000 ? 300 : Math.min(this.backoff * 2, 5000); - log(this.serial, `screenrecord terminó (code=${code} signal=${signal}, ${ranMs} ms); reinicio en ${this.backoff} ms`); + log(this.serial, `screenrecord exited (code=${code} signal=${signal}, ${ranMs} ms); restarting in ${this.backoff} ms`); this.restart(this.backoff); }); log(this.serial, `screenrecord ${vw}x${vh} display=${displayId ?? 'default'}`); @@ -318,17 +318,17 @@ class DeviceSession { 'shell', `for i in 1 2 3 4 5 6 7 8 9 10; do [ -f ${f} ] && break; sleep 0.1; done; p=$(cat ${f} 2>/dev/null); ` + `if [ -n "$p" ]; then kill $p 2>/dev/null; for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do kill -0 $p 2>/dev/null || break; sleep 0.1; done; fi; rm -f ${f}`, - ]).catch((err) => log(this.serial, 'no se pudo detener screenrecord:', err.message)); + ]).catch((err) => log(this.serial, 'could not stop screenrecord:', err.message)); } async sendSnapshot(targets) { const { displayId } = this.state; try { - const { data } = await screencap(this.serial, displayId); + const { data } = await screencap(this.serial, displayId, { format: 'bmp' }); const msg = Buffer.concat([Buffer.from([FLAG_IMAGE]), data]); for (const ws of targets) if (ws.readyState === ws.OPEN) ws.send(msg); } catch (err) { - log(this.serial, 'screencap falló:', err.message); + log(this.serial, 'screencap failed:', err.message); } } @@ -395,7 +395,7 @@ class DeviceSession { clearTimeout(this.watchdogTimer); this.watchdogTimer = setTimeout(() => { if (proc && this.recorder === proc && !(proc.lastFrameAt >= sentAt)) { - log(this.serial, 'sin frames tras el input; se reinicia screenrecord'); + log(this.serial, 'no frames after input; restarting screenrecord'); this.requestKeyframe(); } }, INPUT_WATCHDOG_MS); @@ -478,7 +478,7 @@ function readJson(req) { req.on('data', (c) => { size += c.length; if (size > MAX_BODY_BYTES) { - reject(Object.assign(new Error('Cuerpo demasiado grande'), { status: 413 })); + reject(Object.assign(new Error('Request body too large'), { status: 413 })); req.destroy(); } else chunks.push(c); }); @@ -486,7 +486,7 @@ function readJson(req) { try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}); } catch { - reject(Object.assign(new Error('JSON inválido'), { status: 400 })); + reject(Object.assign(new Error('Invalid JSON'), { status: 400 })); } }); req.on('error', reject); @@ -496,18 +496,26 @@ function readJson(req) { async function handleApi(req, res, url) { const route = `${req.method} ${url.pathname}`; if (url.pathname === '/api/screenshot' && req.method === 'GET') { - const { data, mimeType } = await actions.screenshot({ serial: await resolveSerial(url.searchParams.get('serial')) }); - res.writeHead(200, { 'content-type': mimeType, 'cache-control': 'no-store' }); - res.end(data); + const shot = await actions.screenshot({ + serial: await resolveSerial(url.searchParams.get('serial')), + max: url.searchParams.get('max'), + }); + res.writeHead(200, { + 'content-type': shot.mimeType, + 'cache-control': 'no-store', + 'x-screen-width': shot.screenWidth, + 'x-screen-height': shot.screenHeight, + }); + res.end(shot.data); return; } const handler = API[route]; - if (!handler) throw Object.assign(new Error('Ruta no encontrada'), { status: 404 }); + if (!handler) throw Object.assign(new Error('Not found'), { status: 404 }); let params = Object.fromEntries(url.searchParams); if (req.method === 'POST') { // JSON obligatorio: una página ajena no puede mandarlo sin preflight CORS, que aquí no se concede. if (!/^application\/json\b/.test(req.headers['content-type'] || '') || !isLocalOrigin(req.headers.origin)) { - throw Object.assign(new Error('Se requiere application/json desde un origen local'), { status: 403 }); + throw Object.assign(new Error('application/json from a local origin is required'), { status: 403 }); } params = { ...params, ...(await readJson(req)) }; } @@ -554,7 +562,7 @@ const wss = new WebSocketServer({ wss.on('connection', async (ws, req) => { const serial = new URL(req.url, 'http://x').searchParams.get('serial') || ''; if (!SERIAL_RE.test(serial)) { - ws.close(1008, 'serial inválido'); + ws.close(1008, 'invalid serial'); return; } let session = sessions.get(serial); @@ -571,18 +579,18 @@ wss.on('connection', async (ws, req) => { } catch { return; } - session.handle(msg, ws).catch((err) => log(serial, 'mensaje falló:', err.message)); + session.handle(msg, ws).catch((err) => log(serial, 'message failed:', err.message)); }); try { await session.add(ws); } catch (err) { - log(serial, 'no se pudo iniciar:', err.message); + log(serial, 'could not start:', err.message); session.send(ws, { type: 'error', message: err.message }); ws.close(); } }); -server.listen(PORT, HOST, () => log(`Android Viewer en http://${HOST === '127.0.0.1' ? 'localhost' : HOST}:${PORT}/ (adb: ${ADB})`)); +server.listen(PORT, HOST, () => log(`Android Viewer on http://${HOST === '127.0.0.1' ? 'localhost' : HOST}:${PORT}/ (adb: ${ADB})`)); for (const sig of ['SIGINT', 'SIGTERM']) { process.on(sig, () => {