feat: publish sanitized Linux cloud-game MAA adapter

This commit is contained in:
RRRosmontis
2026-09-16 11:11:35 +08:00
commit de064272ec
23 changed files with 2741 additions and 0 deletions
Executable
+205
View File
@@ -0,0 +1,205 @@
#!/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("<III", image.width, image.height, 1) + image.tobytes()
def emit(data: bytes | str) -> 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}")
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${MAA_ONLINE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
export HOME="${MAA_ONLINE_HOME:-$ROOT/runtime/home}"
export MAA_CONFIG_DIR="${MAA_CONFIG_DIR:-$ROOT/config}"
export MAA_ONLINE_API="${MAA_ONLINE_API:-http://127.0.0.1:22888}"
MAA_BIN="${MAA_BIN:-$ROOT/runtime/bin/maa}"
if [ "$#" -eq 0 ]; then
exec "$MAA_BIN" --help
fi
command_name="$1"
shift
case "$command_name" in
run|startup|closedown|fight|copilot|ssscopilot|paradoxcopilot|roguelike|reclamation)
exec "$MAA_BIN" "$command_name" --profile cloud "$@"
;;
*)
exec "$MAA_BIN" "$command_name" "$@"
;;
esac
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${MAA_ONLINE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
exec python3 "$ROOT/user_config/edit_config.py" "$@"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${MAA_ONLINE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
# Cloud gaming should normally connect directly rather than inherit a host proxy.
if [ "${MAA_ONLINE_KEEP_PROXY:-0}" != "1" ]; then
unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy
fi
cd "$ROOT/backend"
exec "$ROOT/runtime/venv/bin/python" server.py
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${MAA_ONLINE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
export HOME="${MAA_ONLINE_HOME:-$ROOT/runtime/home}"
export MAA_CONFIG_DIR="${MAA_CONFIG_DIR:-$ROOT/config}"
MAA_BIN="${MAA_BIN:-$ROOT/runtime/bin/maa}"
retry() {
local attempt
for attempt in 1 2 3; do
if "$@"; then return 0; fi
echo "Attempt ${attempt}/3 failed: $*" >&2
sleep $((attempt * 2))
done
return 1
}
direct() {
if [ "${MAA_ONLINE_KEEP_PROXY:-0}" = "1" ]; then "$@"; else env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY "$@"; fi
}
echo '[1/3] Updating maa-cli'
retry direct timeout 90s "$MAA_BIN" self update
echo '[2/3] Updating official MaaCore and base resources'
retry direct timeout 150s "$MAA_BIN" update stable --batch
resource_repo="$HOME/.local/share/maa/MaaResource"
if [ -d "$resource_repo/.git" ]; then git -C "$resource_repo" config http.version HTTP/1.1; fi
echo '[3/3] Updating hot resources'
retry direct timeout 90s "$MAA_BIN" hot-update --batch
echo 'Update workflow completed.'