#!/usr/bin/env python3 """Small ADB-command compatibility shim backed by maa-online HTTP API. It lets an unmodified MaaCore/maa-cli use the cloud-game backend, preserving maa-cli's normal `maa self update` and `maa update` mechanisms. """ from __future__ import annotations import gzip import json import os import shlex import socket import struct import sys import urllib.error import urllib.request from io import BytesIO from pathlib import Path try: from PIL import Image except ModuleNotFoundError: root = Path(__file__).resolve().parents[1] venv_python = root / "runtime/venv/bin/python" if venv_python.is_file() and Path(sys.executable) != venv_python: os.execv(venv_python, [str(venv_python), str(Path(__file__).resolve()), *sys.argv[1:]]) raise BASE_URL = os.getenv("MAA_ONLINE_API", "http://127.0.0.1:22888").rstrip("/") SERIAL = os.getenv("MAA_ONLINE_ADB_SERIAL", "cloud-game:22888") TIMEOUT = float(os.getenv("MAA_ONLINE_ADB_TIMEOUT", "40")) def request(path: str, payload: dict | None = None) -> tuple[int, bytes, dict[str, str]]: data = None headers: dict[str, str] = {} method = "GET" if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" method = "POST" req = urllib.request.Request(BASE_URL + path, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=TIMEOUT) as response: return response.status, response.read(), dict(response.headers.items()) except urllib.error.HTTPError as exc: return exc.code, exc.read(), dict(exc.headers.items()) def fetch_png() -> bytes: status, body, headers = request("/screencap") if status != 200: raise RuntimeError(f"screencap failed: HTTP {status}: {body.decode(errors='replace')}") content_type = headers.get("Content-Type", "") if "image/png" not in content_type: raise RuntimeError(f"unexpected screenshot content type: {content_type}") return body def fetch_raw_screencap() -> bytes: image = Image.open(BytesIO(fetch_png())).convert("RGBA") # Android screencap raw format: little-endian width, height, format=RGBA_8888. return struct.pack(" None: if isinstance(data, str): data = data.encode("utf-8") sys.stdout.buffer.write(data) def fail(message: str, code: int = 1) -> int: print(message, file=sys.stderr) return code def strip_global_options(argv: list[str]) -> list[str]: result: list[str] = [] index = 0 options_with_value = {"-s", "-H", "-P", "-L", "--one-device"} while index < len(argv): if argv[index] in options_with_value and index + 1 < len(argv): index += 2 continue if argv[index] in {"-d", "-e", "-a"}: index += 1 continue result.extend(argv[index:]) break return result def shell(command: str) -> int: normalized = " ".join(command.strip().split()) if "settings get secure android_id" in normalized: emit("6d61616f6e6c696e\n") elif "getprop ro.build.version.release" in normalized: emit("9\n") elif "getprop ro.product.cpu.abilist" in normalized: emit("x86_64,arm64-v8a,armeabi-v7a\n") elif normalized.startswith("wm size"): status, body, _ = request("/info") if status != 200: return fail(f"cloud backend unavailable: HTTP {status}") info = json.loads(body) if info.get("status") != "ok": return fail(f"cloud backend is {info.get('status', 'not ready')}") emit(f"{int(info['width'])}\n{int(info['height'])}\n") elif "SurfaceOrientation" in normalized: emit("0\n") elif "SurfaceFlinger --latency" in normalized: emit("33333333\n") elif normalized.startswith("input tap "): fields = normalized.split() status, body, _ = request("/click", {"x": int(fields[-2]), "y": int(fields[-1])}) if status != 200: return fail(f"click failed: HTTP {status}: {body.decode(errors='replace')}") elif normalized.startswith("input swipe "): fields = normalized.split() status, body, _ = request( "/swipe", { "x1": int(fields[-5]), "y1": int(fields[-4]), "x2": int(fields[-3]), "y2": int(fields[-2]), "duration": int(fields[-1]), }, ) if status != 200: return fail(f"swipe failed: HTTP {status}: {body.decode(errors='replace')}") elif normalized.startswith("input text "): text = normalized.removeprefix("input text ") status, body, _ = request("/input", {"text": text}) if status != 200: return fail(f"text input failed: HTTP {status}: {body.decode(errors='replace')}") elif normalized.startswith("input keyevent "): return fail("cloud backend key events are not implemented") elif normalized.startswith("am start "): status, body, _ = request("/start", {}) if status not in {200, 202}: return fail(f"cloud start failed: HTTP {status}: {body.decode(errors='replace')}") # MaaCore's legacy startup probe treats a sufficiently long `am start` # response as confirmation that Android accepted the launch request. emit( "Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] " "flg=0x10200000 cmp=com.hypergryph.arknights/com.u8.sdk.U8UnityContext }\n" "Status: ok\nActivity: com.hypergryph.arknights/com.u8.sdk.U8UnityContext\n" ) elif normalized.startswith("am force-stop "): status, body, _ = request("/exit", {}) if status != 200: return fail(f"cloud stop failed: HTTP {status}: {body.decode(errors='replace')}") elif normalized.startswith(("dumpsys input", "cat /proc/net/arp")): emit("0\n") else: return fail(f"unsupported adb shell command: {normalized}") return 0 def main(argv: list[str]) -> int: args = strip_global_options(argv) if not args: return fail("adb-cloud: missing command") if args[0] == "devices": emit(f"List of devices attached\n{SERIAL}\tdevice\n") return 0 if args[0] == "connect": emit(f"already connected to {args[1] if len(args) > 1 else SERIAL}\n") return 0 if args[0] in {"start-server", "kill-server", "wait-for-device"}: return 0 if args[0] == "get-state": emit("device\n") return 0 if args[0] == "exec-out": command = " ".join(args[1:]) if "screencap" not in command: return fail(f"unsupported adb exec-out command: {command}") if "nc " in command: fields = shlex.split(command) nc_index = fields.index("nc") # MaaCore passes the host address as seen from an Android guest # (often 10.0.2.2). The shim itself runs on the host, so loop back. host, port = "127.0.0.1", int(fields[-1]) if nc_index < 0: return fail(f"invalid nc screencap command: {command}") with socket.create_connection((host, port), timeout=TIMEOUT) as connection: connection.sendall(fetch_raw_screencap()) return 0 if "gzip" in command: emit(gzip.compress(fetch_raw_screencap(), compresslevel=1)) return 0 emit(fetch_png()) return 0 if args[0] == "shell": return shell(" ".join(args[1:])) return fail(f"unsupported adb command: {' '.join(args)}") if __name__ == "__main__": try: raise SystemExit(main(sys.argv[1:])) except (OSError, RuntimeError, ValueError, KeyError, json.JSONDecodeError) as exc: raise SystemExit(f"adb-cloud: {exc}")