feat: publish sanitized Linux cloud-game MAA adapter
This commit is contained in:
Executable
+205
@@ -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
@@ -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
|
||||
Executable
+4
@@ -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" "$@"
|
||||
Executable
+11
@@ -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
|
||||
Executable
+34
@@ -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.'
|
||||
Reference in New Issue
Block a user