From 93d4f59e38b305d68883723b5d6166cf290e14d9 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 14:20:03 -0600 Subject: [PATCH 1/6] chore(git-flow): add CHANGELOG, native git hooks and CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG.md (Keep a Changelog) con lo que trae la primera versión. - .githooks/: commit-msg valida Conventional Commits y pre-commit bloquea commits directos en main y beta; npm install los activa. - CONTRIBUTING.md con el modelo de ramas, commits y releases. - CI en GitHub Actions: npm ci y npm run check en PRs y pushes a beta y main. Co-Authored-By: Claude Opus 5.5 --- .githooks/README.md | 28 ++++++++++++++ .githooks/commit-msg | 53 +++++++++++++++++++++++++++ .githooks/pre-commit | 36 ++++++++++++++++++ .github/workflows/ci.yml | 27 ++++++++++++++ CHANGELOG.md | 27 ++++++++++++++ CONTRIBUTING.md | 79 ++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++ package.json | 4 +- 8 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 .githooks/README.md create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md 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..f58083c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# 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 +- 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. +- Git flow del proyecto: CHANGELOG, guía de contribución, hooks de git (Conventional Commits y ramas protegidas) y CI en GitHub Actions. + +### Cambiado + +### Corregido + +### Eliminado + + 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..280ae1a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,10 @@ Con alguien mirando el visor, las acciones pasan por su sesión: el panel se act 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. +## Contribuir + +Modelo de ramas (`main` · `beta`), Conventional Commits y releases en [CONTRIBUTING.md](./CONTRIBUTING.md); cambios en [CHANGELOG.md](./CHANGELOG.md). + ## Author Made by [AcertijosApps](https://acertijosapps.com/librerias). See the rest of our libraries at diff --git a/package.json b/package.json index dcd24c1..11a4e70 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ ], "scripts": { "start": "node server.js", - "mcp": "node mcp/server.js" + "mcp": "node mcp/server.js", + "check": "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", From 85e41c14e22f080fa19af2e943ee03614e80fec7 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 14:43:32 -0600 Subject: [PATCH 2/6] feat: add android-viewer CLI for npx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npx @acertijosapps/android-viewer` arranca el visor y `npx @acertijosapps/android-viewer mcp` el MCP por stdio, con --port, --host, --viewer-url, --help y --version. Las opciones se pasan por process.env antes de importar el módulo. El MCP toma su versión de package.json. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + bin/android-viewer.js | 79 +++++++++++++++++++++++++++++++++++++++++++ mcp/server.js | 4 ++- package.json | 10 ++++-- 4 files changed, 90 insertions(+), 4 deletions(-) create mode 100755 bin/android-viewer.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f58083c..aa101f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/); version - 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. +- 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`. - Git flow del proyecto: CHANGELOG, guía de contribución, hooks de git (Conventional Commits y ramas protegidas) y CI en GitHub Actions. ### Cambiado 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/mcp/server.js b/mcp/server.js index 2cfd65b..beeb860 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -7,6 +7,7 @@ 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'; @@ -87,7 +88,8 @@ const serialArg = z .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`); -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', diff --git a/package.json b/package.json index 11a4e70..91f55dc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,11 @@ "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", @@ -13,9 +17,9 @@ "LICENSE" ], "scripts": { - "start": "node server.js", - "mcp": "node mcp/server.js", - "check": "node --check server.js && node --check lib/device.js && node --check mcp/server.js && node --check public/viewer.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": [ From 0d3289794149d82014c055c885e188c4cbfcf872 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 14:45:52 -0600 Subject: [PATCH 3/6] feat: encode screenshots in pure JavaScript instead of sips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La captura se pide en crudo (RGBA), se reduce por promedio de área y se codifica como PNG con node:zlib, así que el MCP ya no depende de sips (macOS) y funciona en Linux y Windows. GET /api/screenshot acepta ?max= y devuelve el tamaño de la pantalla en X-Screen-Width y X-Screen-Height; la descarga del visor baja el PNG directo. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 +- lib/device.js | 129 ++++++++++++++++++++++++++++++++++++++++++----- mcp/server.js | 47 +++++------------ public/viewer.js | 11 +--- server.js | 16 ++++-- 6 files changed, 144 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa101f9..24087f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/); version - 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`. - 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/README.md b/README.md index 280ae1a..6fd1b96 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Registrado en `.mcp.json` como `android-viewer`. Claude Code pide habilitarlo la | `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`) | +| `screenshot` | Captura del display activo: PNG con lado mayor ≤ 1024, sin herramientas del sistema | | `tap` | `input tap` | | `swipe` | `input swipe`; mismo punto y ≥ 500 ms = pulsación larga | | `press_key` | back, home, recents, power, wakeup, volup, voldown, enter, delete, tab, escape | diff --git a/lib/device.js b/lib/device.js index 849695e..9ed3010 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 || @@ -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 beeb860..88624a4 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -3,15 +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; @@ -43,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. @@ -57,29 +59,6 @@ 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 @@ -127,19 +106,17 @@ server.registerTool( { title: 'Captura de pantalla', description: - 'Captura la pantalla del display activo. Para tap/swipe usa coordenadas normalizadas: ' + + 'Captura la pantalla del display activo (PNG). Para tap/swipe usa coordenadas normalizadas: ' + 'x = píxel_x / ancho de la imagen, y = píxel_y / alto de la imagen.', 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: `Pantalla de ${shot.screenWidth}×${shot.screenHeight} px (imagen con lado mayor ≤ ${SCREENSHOT_MAX}).` }, ], }; }, diff --git a/public/viewer.js b/public/viewer.js index edcf67e..5c30820 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -301,7 +301,7 @@ 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(); @@ -309,15 +309,8 @@ $('#screenshot').onclick = async () => { 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); diff --git a/server.js b/server.js index 79bb7d0..d10077d 100644 --- a/server.js +++ b/server.js @@ -324,7 +324,7 @@ class DeviceSession { 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) { @@ -496,9 +496,17 @@ 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]; From 31924cdf5faf77f038408ef9293e997ba4fcc47e Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 14:47:27 -0600 Subject: [PATCH 4/6] docs: translate the README to English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README en inglés para npm y GitHub: captura del visor con el plegable (media/, servida por unpkg como en react-native-fold), requisitos, inicio con npx, configuración del MCP para Claude Code y otros clientes, API HTTP, funcionamiento y limitaciones. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 209 ++++++++++++++++++++++++-------------- media/viewer-foldable.jpg | Bin 0 -> 32651 bytes package.json | 1 + 4 files changed, 135 insertions(+), 76 deletions(-) create mode 100644 media/viewer-foldable.jpg diff --git a/CHANGELOG.md b/CHANGELOG.md index 24087f4..ccdbcbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/); version - 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. - Git flow del proyecto: CHANGELOG, guía de contribución, hooks de git (Conventional Commits y ramas protegidas) y CI en GitHub Actions. ### Cambiado diff --git a/README.md b/README.md index 6fd1b96..428e712 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,165 @@ -# 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 +Keep the viewer running (`npx @acertijosapps/android-viewer`) to watch what the agent does. Without +it, the MCP drives the device through `adb` directly. -Registrado en `.mcp.json` como `android-viewer`. Claude Code pide habilitarlo la primera vez que se abre una sesión en esta carpeta. - -| 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: PNG con lado mayor ≤ 1024, sin herramientas del sistema | +| `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). - -Para probarlo a mano: `npm run mcp` (habla MCP por stdin/stdout). +| `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 | -## API HTTP del visor +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. -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. +### In the Claude Code browser pane -- `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}` +Add the viewer to `.claude/launch.json` and open it with `preview_start android-viewer`: -`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 - -- 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. - -## Por qué no ws-scrcpy +```json +{ + "version": "0.0.1", + "configurations": [ + { + "name": "android-viewer", + "runtimeExecutable": "npx", + "runtimeArgs": ["-y", "@acertijosapps/android-viewer"], + "port": 8093, + "autoPort": false + } + ] +} +``` -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. +## HTTP API -## Contribuir +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. -Modelo de ramas (`main` · `beta`), Conventional Commits y releases en [CONTRIBUTING.md](./CONTRIBUTING.md); cambios en [CHANGELOG.md](./CHANGELOG.md). +| 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. +- The viewer UI is in Spanish for now. + +## 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/media/viewer-foldable.jpg b/media/viewer-foldable.jpg new file mode 100644 index 0000000000000000000000000000000000000000..220a40f8aef6ac5f150657d6e896c2201aa0d743 GIT binary patch literal 32651 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 z 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
    ^Xa6z&o4w=yB!;3S>NGnQaC^ACE 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

    5FQxIo=Lf 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 rss}(&O?<+S!wCTRI{beCA6d|7 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 91f55dc..a2c3c13 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "lib", "mcp", "public", + "media", "README.md", "LICENSE" ], From adcbecd57fd531204b8dc58d866f4f7b344e7677 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 15:19:38 -0600 Subject: [PATCH 5/6] feat: show the viewer, API errors and MCP tools in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Para la versión pública: textos de la interfaz (selector, controles, posturas, avisos), errores de la API, títulos y descripciones de las herramientas del MCP, sus respuestas y los mensajes de la terminal. Los comentarios del código siguen en español. La captura del README ya muestra los controles en inglés. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 1 - lib/device.js | 24 ++++----- mcp/server.js | 100 +++++++++++++++++++------------------- media/viewer-foldable.jpg | Bin 32651 -> 34282 bytes public/index.html | 36 +++++++------- public/viewer.js | 34 ++++++------- server.js | 34 ++++++------- 8 files changed, 115 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccdbcbe..496f06e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/); version - 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. ### Cambiado diff --git a/README.md b/README.md index 428e712..f2b26bf 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,6 @@ once and a watchdog restarts the stream if a gesture produces no frames. 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. -- The viewer UI is in Spanish for now. ## Why not ws-scrcpy? diff --git a/lib/device.js b/lib/device.js index 9ed3010..42109fb 100644 --- a/lib/device.js +++ b/lib/device.js @@ -73,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(); @@ -133,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', () => {}); @@ -143,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']); } @@ -155,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='([^']+)'/); @@ -198,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, `'\\''`)}'`; } @@ -221,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 diff --git a/mcp/server.js b/mcp/server.js index 88624a4..8d1ba6e 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -31,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) { @@ -64,8 +64,8 @@ const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'str 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 { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); const server = new McpServer({ name: 'android-viewer', version }); @@ -73,10 +73,10 @@ 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), @@ -85,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 }, }, @@ -96,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`)', }); }, ); @@ -104,10 +104,10 @@ server.registerTool( server.registerTool( 'screenshot', { - title: 'Captura de pantalla', + title: 'Screenshot', description: - 'Captura la pantalla del display activo (PNG). 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 }, }, @@ -116,7 +116,7 @@ server.registerTool( return { content: [ { type: 'image', data: shot.data.toString('base64'), mimeType: shot.mimeType }, - { type: 'text', text: `Pantalla de ${shot.screenWidth}×${shot.screenHeight} px (imagen con lado mayor ≤ ${SCREENSHOT_MAX}).` }, + { type: 'text', text: `Screen is ${shot.screenWidth}×${shot.screenHeight} px (image scaled to a longest side ≤ ${SCREENSHOT_MAX}).` }, ], }; }, @@ -125,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), ); @@ -198,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, }, }, @@ -226,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 index 220a40f8aef6ac5f150657d6e896c2201aa0d743..bf4f8633aa2adf7fe8cc53b8a343b1b00ab80745 100644 GIT binary patch literal 34282 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 o&%V#cnC*-vPW}E)x4H~jY5_r3rB0ABeFQUCw| literal 32651 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 z zxM_VcZ0>pdOBXHccaIhk0(07eZpwYiXaetwjjl=Gj0@|MRVi8|2UWVAm>!j@6)G6! zF}gV9`c3q%Kq`&zjTM@4$5;w-g&EaJwq)xMdxbW>oZ_#_+Uw$WC(&=qdb3}#o(|F#C+>6_5)4&~&h$s=RQBU;NW78R-}r!3+2FAp@Pk`0}9R4z7t>iN`&btogyKK>~)>kwO{$J6+y zEWu*XA&-kgYWdSzsFq+IOkGEgY6yWV|E;lJU#Ix%HLmI?%Pdpzy~z%o zR|^fsmf6%OSU=&7i#Jpkhgm~gQ6fAtvf7=aZd$wQsc?x66l1p@l#50bS&>dMK0iEa z17!S%5dZFxa_AOt=FfZ3q`q=u_8mXusnwAgvE|knRBa;q+Hkfi{kzhFeIF_gk?>jn zc0)R}&zYq}prI19S@u*P3(+~@gic#~wWjE%v{~m1w30LjS2rIh`j40J1HuFNo3(2ROK`FDpOl5D;MuWCL=1vSP8mf9_Q3{P4v&zlN zboDM$ZuoqTCL8I^QP!y98kEJ168z}y!sB!q;=DHW5@9&2R@KVW?;`TXFXFSV!vJI9 zur9OrtkTR&Hd`KavWPdbv(Q-3F*pXH2&uYgNQe?p=h zd$JG2=LZZgfLr-e@=devsfW_eD6V+y15eK~@SLl`NUNaL?MGXjt#ZL&kanS61a$9X z7ZpJV-O=2o9`K*74N|BY*ji=>iVI)+W*j}Z$ zI7^#<%&Lz!)%-Kx&d9s?>3(}B{v89$f;MICFg)HZRx~s&++MikLco>S?l|8sVLFR8 zor!T=JUs-*R9opG<*FvhQ*G-NjL*TNINU#3I-d03HW~03(BBx1<;^X7FWPUqvXhE8 z%2__AB+j;RZDj`3M?7?sspb!WyEd$FPd2BwWg|0-w@WnXxwg3 z3;GmkvpW;54Ri>Mix=lrhxkBTm^Kq_Ici2YK20ORs=S4_CCzw-JQ``#%9r)kL)$IZ z%sz9{N0Ez1;nA8=(PhU5e**o;t@_J`V#Onfqgp=ecO%C>wFl!j!^PiZb8W5S;2XgTUf@}&KPmg43;u5O5!2(xD714db`=bD6FY0@1gqi*5Q+EzGj(!;**I~aVNgBsfZp1cCHcHdin^L@* zjYFJoB451AnAUb@kCqF|oYvIS73S_JlqWc5ycU~AUb?fh%f#3y+#ktlA(O2z6c;+E z73zjm8Fn;?h-{>4jB4MQb=(PpXw9liR#i~Ss5}vLZ;z6#m(ojg3SZ^ZtBOO7v^Btr z{AMK83gc{%124Chs8g-^n~mCp!O_%jANe!)|CQJLozLWkfm7s9ox|qeIz8Q_xfJ$+ zwn5vKOS^Mrj^}S+_W|LFm|<)rCFnDdP9r4t;A_|x*;$;D^^)(Ue6PHuo=QCVYBEfh z0zR6NligruDT+v0N4g}_*j%t+bciA60VD0s4D;RM)c6V!NSLBhrf!GmSSIA_c2UL{ zVnuqplX{^HD;aD_$SQ4Z_Fy}zx2WYJ27a=w#rZoq3e^%U3dm+OOO#*o-D(^bX@Bn9 zG#h*nAHV}%*ZwSppCt#9`ujxcln+q;^0hzENVu?-qERPyQ_fdy<=pG3|8xxf-27uo zm8|ztVYy1eZ)t4yvU|GCT_(6P>GZqd>9<~tgkK<}SNL@1Z>puRYzjQojZuc`leu^^ zc{WD7=uCaIxwxd1%V$F_do;9wM#W}+{WHUR&=8f^SCd7r*^LAikKpRnQv=xgI zgM#w-VE{j4=pS7D74!@B@Wk401fn>8faEV6EyvzaZ`&hFeui63nK83$w@>|B_CSB5 z>dedjnAo>LHW%UKFCIIDm+M4z7MDiXCw>ccYIG{{j?&0pY|fdEa7m34CiTJ-K##T} zxdcwZfuq-c_xZ2DKP&BDF+t5=1O8?t0+8rl1MEQTe=v~$?CG$*gAV?)0uUK`PO?l6 z5dYnD1^}2dl>lDw)!)O}gLeLl#QX}7Ed6_&zX@JH`VzhW`$cYWs`Yotwpjn>W&ca{ zXVVzuAx9God%WdhR|1+SkIQ_d+UqQ#E>)*%bmoHRWDZk@S?uP4n@7ao-jPP^+CJS* z-eq;qm*V>U#P%Ed`|^@^9_mR{XTNsRe!THj@i%mB?$-bDa>b;xzx(|KU7Ne@@qb^r z>Q(XG#9n);7v}_c)P$N%8W8(H-+4e7lkU6}&=SWk2F&Ev)}IcHX|OF_BD5J6!6v3;PTgvVC0ummqCZ zogfv;SgONCrf?~`BHEzOt&`&jHqKEXORz_&Es#p)0wLTGBBAt|7rmalSlnY+80OY` z&%4#@dvki~pox@DCXAr7*r79}9@;y>l2YWTt#2e(*?!FUaB{(^wHjA(Rn zfab8QY2QX^Z`q)&=YL7)&x22*^~a%>so%{#nwMZjz4q zt7JHCWk{?eX9Q3E1d{<_Mj{)_v>x(}z4>Ae>DzGln#%<%J?Y4MDGyeNn&8rONR6lMWG}HT8-DKlD*sjf zmkEQj36GT<-AhxGX6Orwt&U%myiaoZ1t7xsGkfqSsgPddScf;$KG@!SQ zVKZtUh)~q){f?@B{7~;c36}Msts~4TzDma*GUg&#flh%BUsGBN966{v`7A!85o{@q^wM>(g;3NmK%Lb|f5xwicJSPlc0fs_3CkYq`PYom_mkR!EHN^8~Xm z8$$_K@NNiuC|z1qn0AyF8gA38H?Z?kT6i+Y-b7<6WI?;HHxG4(fH96ncg|XI$e|W_ zk+@@`@jCH(-X1pG?{F?uP|;)<^QJjn%m>l}Y17r&Xz^_9D`Sn)JcgqsdE}_tqea6{ zc`SBv8lbt+c8xHI8}{l+yzYb?(ykoB;3EW_0D1^}I%KGX=VxJ}D?pqR|K6<7RJLB;76Y znUH);v+=RSr#M@bR{z$<^K^R`6%N=Kiq6ztfeie-e!FFH`P)()_ef*(WJZqc2T+|S{M@DZfyk5sq>#

      ^Xa6z&o4w=yB!;3S>NGnQaC^ACE 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

      5FQxIo=Lf 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 rss}(&O?<+S!wCTRI{beCA6d|7 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 5c30820..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 }); } }; @@ -305,7 +305,7 @@ document.querySelectorAll('[data-rotate]').forEach((b) => (b.onclick = () => sen $('#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); @@ -316,7 +316,7 @@ $('#screenshot').onclick = async () => { setTimeout(() => URL.revokeObjectURL(a.href), 1000); renderStatus(); } catch (err) { - renderStatus(`Captura falló: ${err.message}`); + renderStatus(`Screenshot failed: ${err.message}`); } }; @@ -382,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 d10077d..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,7 +318,7 @@ 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) { @@ -328,7 +328,7 @@ class DeviceSession { 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); @@ -510,12 +510,12 @@ async function handleApi(req, res, url) { 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)) }; } @@ -562,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); @@ -579,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, () => { From acf2da1f8124350b8e4ceaad48a49cac561498f7 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 15:19:55 -0600 Subject: [PATCH 6/6] chore(release): 1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Versión 1.0.0 en package.json y package-lock.json; la sección [Sin liberar] del CHANGELOG pasa a [1.0.0] - 2026-11-01 y queda una nueva vacía encima. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 ++++++++++------ package-lock.json | 8 ++++++-- package.json | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 496f06e..ad70397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/); version ## [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. @@ -18,12 +28,6 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/); version - 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. -### Cambiado - -### Corregido - -### Eliminado -