Files
DroidDeck/tools/droiddeckctl
T

352 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Control a debug DroidDeck build over ADB."""
from __future__ import annotations
import argparse
import base64
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from adb_device import DeviceResolutionError, resolve_serial
AUTHORITY = "content://com.droiddeck.launcher.agent"
class ControlError(RuntimeError):
def __init__(self, message: str, code: str = "COMMAND_FAILED") -> None:
super().__init__(message)
self.code = code
def adb_path(configured: str | None) -> str:
if configured:
return configured
environment_adb = os.environ.get("ADB")
if environment_adb:
return environment_adb
found = shutil.which("adb")
if found:
return found
sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")
if sdk:
candidate = Path(sdk) / "platform-tools" / "adb"
if candidate.is_file():
return str(candidate)
home_dir = Path.home()
candidate = home_dir / "Library" / "Android" / "sdk" / "platform-tools" / "adb"
if candidate.is_file():
return str(candidate)
raise ControlError("adb not found; set ADB, ANDROID_HOME, or ANDROID_SDK_ROOT", "ADB_NOT_FOUND")
def run_adb(adb: str, serial: str, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[bytes]:
try:
return subprocess.run(
[adb, "-s", serial, *args], capture_output=True, check=False, timeout=timeout
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ControlError(str(exc), "ADB_FAILED") from exc
def _content_result(output: str) -> dict:
marker = "json="
start = output.find(marker)
if start < 0:
detail = output.strip() or "The debug agent provider returned no result"
raise ControlError(detail, "INVALID_PROVIDER_RESPONSE")
raw = output[start + len(marker):].lstrip()
try:
response, _ = json.JSONDecoder().raw_decode(raw)
if not isinstance(response, dict):
raise ValueError("expected an object")
return response
except (json.JSONDecodeError, ValueError) as exc:
raise ControlError("Could not decode the agent provider response", "INVALID_PROVIDER_RESPONSE") from exc
def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None) -> dict:
command = ["shell", "content", "call", "--uri", AUTHORITY, "--method", method]
if arg is not None:
command.extend(["--arg", arg])
if request is not None:
encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
command.extend(["--extra", f"request:s:{encoded}"])
result = run_adb(adb, serial, *command)
output = (result.stdout + result.stderr).decode(errors="replace")
response = _content_result(output)
if result.returncode != 0 and response.get("ok") is not False:
raise ControlError(output.strip() or "ADB content call failed", "ADB_FAILED")
return response
def start_agent_activity(adb: str, serial: str, request: dict) -> None:
encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
result = run_adb(
adb,
serial,
"shell",
"am",
"start",
"-W",
"-n",
"com.droiddeck.launcher/.agent.AgentStartActivity",
"--es",
"request",
encoded,
timeout=90,
)
output = (result.stdout + result.stderr).decode(errors="replace")
if result.returncode != 0 or "status: ok" not in output.lower():
raise ControlError(output.strip() or "The agent start Activity did not launch", "ACTIVITY_START_FAILED")
def confirm_start(adb: str, serial: str, request: dict, previous: dict) -> dict:
deadline = time.monotonic() + 95
expected_mode = {"steam": "steam", "desktop": "lxqt", "run": "run"}[request["mode"]]
while True:
state = get_state(adb, serial)
session = state.get("session", {})
phase = session.get("phase")
if session.get("mode") == expected_mode and session.get("id") != previous.get("id"):
return state
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ControlError(
f"The agent Activity launched but no new session appeared; phase is {phase}",
"ACTIVITY_START_FAILED",
)
time.sleep(min(0.25, remaining))
def get_state(adb: str, serial: str) -> dict:
response = provider_call(adb, serial, "state")
if response.get("ok") is False:
error = response.get("error", {})
raise ControlError(error.get("message", "state failed"), error.get("code", "STATE_FAILED"))
return response
def wait_for_phase(adb: str, serial: str, wanted: str, timeout: float) -> dict:
deadline = time.monotonic() + timeout
last_state: dict | None = None
while True:
last_state = get_state(adb, serial)
session = last_state.get("session", {})
phase = session.get("phase")
if phase == wanted:
return last_state
if phase == "FAILED" and wanted != "FAILED":
failure = session.get("failure") or {}
raise ControlError(
f"Session failed ({failure.get('code', 'FAILED')}): {failure.get('message', 'unknown error')}",
failure.get("code", "SESSION_FAILED"),
)
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ControlError(
f"Timed out waiting for {wanted}; last phase was {phase}", "TIMEOUT"
)
time.sleep(min(1.0, remaining))
def wait_for_artifacts(adb: str, serial: str, state: dict, timeout: float) -> dict:
deadline = time.monotonic() + timeout
while True:
session = state.get("session", {})
if session.get("artifactsComplete") or not session.get("artifactsAvailable"):
return state
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ControlError("Session stopped, but artifact collection did not complete", "ARTIFACT_TIMEOUT")
time.sleep(min(1.0, remaining))
state = get_state(adb, serial)
def add_serial(response: dict, serial: str) -> dict:
response["deviceSerial"] = serial
return response
def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
if args.command == "state":
return add_serial(get_state(adb, serial), serial)
if args.command == "start":
current = get_state(adb, serial)
if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"):
raise ControlError("A session is already starting or running", "SESSION_ACTIVE")
request = {"mode": args.mode}
if args.mode == "steam":
if args.ui == "desktop":
request["steamUi"] = "desktop"
if args.url:
if not args.url.startswith("steam://"):
raise ControlError("Steam URL must start with steam://", "INVALID_STEAM_URL")
request["steamUrl"] = args.url
start_agent_activity(adb, serial, request)
started = confirm_start(adb, serial, request, current["session"])
if args.wait:
return add_serial(wait_for_phase(adb, serial, "READY", args.timeout), serial)
session = started["session"]
return add_serial({
"ok": True,
"command": "start",
"mode": args.mode,
"sessionId": session.get("id"),
"phase": session.get("phase"),
}, serial)
if args.command == "run":
current = get_state(adb, serial)
if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"):
raise ControlError("A session is already starting or running", "SESSION_ACTIVE")
program_args = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments
request = {"mode": "run", "program": args.program, "programArgs": program_args}
start_agent_activity(adb, serial, request)
started = confirm_start(adb, serial, request, current["session"])
session = started["session"]
return add_serial({
"ok": True,
"command": "start",
"mode": "run",
"sessionId": session.get("id"),
"phase": session.get("phase"),
}, serial)
if args.command == "wait":
phases = {"ready": "READY", "idle": "IDLE", "failed": "FAILED"}
return add_serial(wait_for_phase(adb, serial, phases[args.phase], args.timeout), serial)
if args.command == "stop":
current = get_state(adb, serial)
if current["session"].get("phase") in {"IDLE", "FAILED"}:
return add_serial(wait_for_artifacts(adb, serial, current, args.timeout), serial)
response = provider_call(adb, serial, "stop")
if response.get("ok") is False:
error = response.get("error", {})
raise ControlError(error.get("message", "stop failed"), error.get("code", "STOP_FAILED"))
state = wait_for_phase(adb, serial, "IDLE", args.timeout)
return add_serial(wait_for_artifacts(adb, serial, state, args.timeout), serial)
if args.command == "resume":
response = provider_call(adb, serial, "resume")
if response.get("ok") is False:
error = response.get("error", {})
raise ControlError(error.get("message", "resume failed"), error.get("code", "RESUME_FAILED"))
return add_serial(response, serial)
if args.command == "logs":
state = get_state(adb, serial)
session = state.get("session", {})
if not session.get("running") and session.get("artifactsAvailable") and not session.get("artifactsComplete"):
state = wait_for_artifacts(adb, serial, state, args.timeout)
session = state.get("session", {})
remote_dir = session.get("logDir")
if not remote_dir or not session.get("id") or not session.get("artifactsAvailable"):
raise ControlError("No session artifact directory is available", "NO_SESSION_LOGS")
destination = Path(args.destination or session["id"]).expanduser().resolve()
result = run_adb(adb, serial, "pull", remote_dir, str(destination), timeout=120)
if result.returncode != 0:
detail = (result.stderr or result.stdout).decode(errors="replace").strip()
raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED")
return add_serial({"ok": True, "command": "logs", "sessionId": session["id"], "path": str(destination)}, serial)
if args.command == "screenshot":
destination = Path(args.path).expanduser().resolve()
result = run_adb(adb, serial, "exec-out", "screencap", "-p", timeout=30)
if result.returncode != 0:
detail = result.stderr.decode(errors="replace").strip()
raise ControlError(detail or "screencap failed", "SCREENSHOT_FAILED")
try:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(result.stdout)
except OSError as exc:
raise ControlError(str(exc), "FILE_IO") from exc
return add_serial({"ok": True, "command": "screenshot", "path": str(destination)}, serial)
raise ControlError(f"Unknown command: {args.command}", "UNKNOWN_COMMAND")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--adb", help="adb executable (defaults to ADB, PATH, or the Android SDK)")
parser.add_argument("--serial", help="ADB serial; defaults to ADB_SERIAL or ANDROID_SERIAL")
commands = parser.add_subparsers(dest="command", required=True)
state = commands.add_parser("state", help="print machine-readable session state")
state.add_argument("--json", action="store_true", help="accepted for explicit machine-readable usage")
start = commands.add_parser("start", help="start Steam or the desktop")
start.add_argument("mode", choices=["steam", "desktop"])
start.add_argument("--ui", choices=["bigpicture", "desktop"], default="bigpicture")
start.add_argument("--url", help="steam:// URL to pass to the client")
start.add_argument("--wait", action="store_true", help="wait for READY after starting")
start.add_argument("--timeout", type=float, default=90)
run = commands.add_parser("run", help="run a guest program under Gamescope")
run.add_argument("program")
run.add_argument("arguments", nargs=argparse.REMAINDER)
wait = commands.add_parser("wait", help="wait for a session phase")
wait.add_argument("phase", choices=["ready", "idle", "failed"])
wait.add_argument("--timeout", type=float, default=90)
stop = commands.add_parser("stop", help="stop the session and wait for IDLE")
stop.add_argument("--timeout", type=float, default=90)
commands.add_parser("resume", help="resume a suspended session")
logs = commands.add_parser("logs", help="pull the latest session artifact directory")
logs.add_argument("which", choices=["latest"])
logs.add_argument("destination", nargs="?")
logs.add_argument("--timeout", type=float, default=90)
screenshot = commands.add_parser("screenshot", help="save a device screenshot")
screenshot.add_argument("path")
return parser
def exit_code(exc: Exception) -> int:
if isinstance(exc, DeviceResolutionError):
return 3
code = getattr(exc, "code", "")
if code in {"ADB_NOT_FOUND", "ADB_FAILED", "INVALID_PROVIDER_RESPONSE"}:
return 3
if code in {"SESSION_ACTIVE", "NO_ACTIVE_SESSION", "SESSION_NOT_SUSPENDED", "SESSION_FAILED",
"GUEST_EXIT", "GUEST_START_FAILED", "RUNTIME_INSTALL_FAILED", "DESKTOP_INSTALL_FAILED",
"ACTIVITY_START_FAILED"}:
return 4
if code in {"TIMEOUT", "ARTIFACT_TIMEOUT"}:
return 5
if code in {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "SCREENSHOT_FAILED", "FILE_IO"}:
return 6
return 2
def main() -> int:
args = build_parser().parse_args()
serial = None
try:
adb = adb_path(args.adb)
serial = resolve_serial(adb, args.serial, log=True)
result = execute(args, adb, serial)
print(json.dumps(result, indent=2, sort_keys=True))
return 0
except (ControlError, DeviceResolutionError) as exc:
error = {"code": getattr(exc, "code", "DEVICE_RESOLUTION_FAILED"), "message": str(exc)}
response = {"ok": False, "error": error}
if serial:
response["deviceSerial"] = serial
print(json.dumps(response, indent=2, sort_keys=True))
return exit_code(exc)
if __name__ == "__main__":
raise SystemExit(main())