diff --git a/backend/runtime_api.py b/backend/runtime_api.py index d8686d2..a406723 100644 --- a/backend/runtime_api.py +++ b/backend/runtime_api.py @@ -1,7 +1,10 @@ +import json import os +import subprocess from pathlib import Path from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse from pydantic import BaseModel from backend.android_tools import find_apks @@ -39,43 +42,13 @@ class RuntimeCommandRequest(BaseModel): def _runtime_commands() -> dict[str, dict]: return { - 'git_pull': { - 'label': 'Git Pull', - 'command': 'git pull', - 'timeout': 600, - }, - 'flutter_clean': { - 'label': 'Flutter Clean', - 'command': _flutter('clean'), - 'timeout': 900, - }, - 'flutter_pub_get': { - 'label': 'Flutter Pub Get', - 'command': _flutter('pub get'), - 'timeout': 900, - }, - 'flutter_build_apk': { - 'label': 'Flutter Build APK', - 'command': _flutter('build apk --release'), - 'timeout': 3600, - }, - 'flutter_run_profile': { - 'label': 'Flutter Run Profile', - 'command': _flutter('run --profile'), - 'timeout': 1800, - 'device': True, - }, - 'adb_reconnect': { - 'label': 'ADB Reconnect', - 'command': f'{_runtime_env_prefix()}adb reconnect', - 'timeout': 120, - }, - 'adb_logcat': { - 'label': 'ADB Logcat Snapshot', - 'command': f'{_runtime_env_prefix()}adb logcat -d -t 300', - 'timeout': 120, - 'device': True, - }, + 'git_pull': {'label': 'Git Pull', 'command': 'git pull', 'timeout': 600}, + 'flutter_clean': {'label': 'Flutter Clean', 'command': _flutter('clean'), 'timeout': 900}, + 'flutter_pub_get': {'label': 'Flutter Pub Get', 'command': _flutter('pub get'), 'timeout': 900}, + 'flutter_build_apk': {'label': 'Flutter Build APK', 'command': _flutter('build apk --release'), 'timeout': 3600}, + 'flutter_run_profile': {'label': 'Flutter Run Profile', 'command': _flutter('run --profile'), 'timeout': 1800, 'device': True}, + 'adb_reconnect': {'label': 'ADB Reconnect', 'command': f'{_runtime_env_prefix()}adb reconnect', 'timeout': 120}, + 'adb_logcat': {'label': 'ADB Logcat Snapshot', 'command': f'{_runtime_env_prefix()}adb logcat -d -t 300', 'timeout': 120, 'device': True}, } @@ -89,22 +62,49 @@ def _workspace_cwd(workspace: str) -> str: def _with_device(command: str, device: str | None, enabled: bool) -> str: if not enabled or not device: return command - if 'adb ' in command: return command.replace('adb ', f'adb -s {device} ', 1) - if 'flutter ' in command and ' -d ' not in command: return f'{command} -d {device}' - return command +def _event(payload: dict) -> str: + return json.dumps(payload, ensure_ascii=False) + '\n' + + +def _stream_command(command: str, cwd: str, timeout: int): + process = subprocess.Popen( + command, + cwd=cwd, + shell=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + + yield _event({'type': 'start', 'message': command}) + + try: + assert process.stdout is not None + for line in iter(process.stdout.readline, ''): + if not line: + break + yield _event({'type': 'line', 'message': line.rstrip()}) + + returncode = process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + returncode = -1 + yield _event({'type': 'error', 'message': 'Command timeout'}) + + yield _event({'type': 'done', 'returncode': returncode}) + + @router.get('/logs') async def runtime_logs(): - return { - 'success': True, - 'logs': get_logs(), - } + return {'success': True, 'logs': get_logs()} @router.get('/commands') @@ -112,11 +112,7 @@ async def runtime_commands(): return { 'success': True, 'commands': [ - { - 'id': command_id, - 'label': config['label'], - 'needs_device': bool(config.get('device')), - } + {'id': command_id, 'label': config['label'], 'needs_device': bool(config.get('device'))} for command_id, config in _runtime_commands().items() ], } @@ -133,39 +129,41 @@ async def runtime_event(payload: dict): async def runtime_command(payload: RuntimeCommandRequest): commands = _runtime_commands() config = commands.get(payload.command) - if not config: raise HTTPException(status_code=400, detail='Runtime command is not allowed') cwd = _workspace_cwd(payload.workspace) - command = _with_device( - config['command'], - payload.device, - bool(config.get('device')), - ) + command = _with_device(config['command'], payload.device, bool(config.get('device'))) add_log(f"Runtime command started: {config['label']}") - result = run_command(command, cwd, timeout=int(config.get('timeout', 900))) + add_log(f"Runtime command finished: {config['label']} (exit {result['returncode']})") - add_log( - f"Runtime command finished: {config['label']} " - f"(exit {result['returncode']})" + return {'success': result['returncode'] == 0, 'command': payload.command, 'label': config['label'], 'result': result} + + +@router.post('/command-stream') +async def runtime_command_stream(payload: RuntimeCommandRequest): + commands = _runtime_commands() + config = commands.get(payload.command) + if not config: + raise HTTPException(status_code=400, detail='Runtime command is not allowed') + + cwd = _workspace_cwd(payload.workspace) + command = _with_device(config['command'], payload.device, bool(config.get('device'))) + + add_log(f"Runtime stream started: {config['label']}") + + return StreamingResponse( + _stream_command(command, cwd, int(config.get('timeout', 900))), + media_type='application/x-ndjson', ) - return { - 'success': result['returncode'] == 0, - 'command': payload.command, - 'label': config['label'], - 'result': result, - } - @router.post('/install-latest-apk') async def runtime_install_latest_apk(payload: RuntimeCommandRequest): cwd = _workspace_cwd(payload.workspace) apks = find_apks(cwd) - if not apks: raise HTTPException(status_code=404, detail='APK not found. Build project first.') @@ -174,17 +172,10 @@ async def runtime_install_latest_apk(payload: RuntimeCommandRequest): adb = f'{_runtime_env_prefix()}adb -s {payload.device}' command = f'{adb} install -r "{apks[0]}"' - add_log(f'Runtime APK install started: {apks[0]}') result = run_command(command, cwd, timeout=900) add_log(f"Runtime APK install finished (exit {result['returncode']})") - - return { - 'success': result['returncode'] == 0, - 'apk': apks[0], - 'device': payload.device, - 'result': result, - } + return {'success': result['returncode'] == 0, 'apk': apks[0], 'device': payload.device, 'result': result} @router.post('/restart-app') @@ -198,14 +189,7 @@ async def runtime_restart_app(payload: RuntimeCommandRequest): adb = f'{_runtime_env_prefix()}adb -s {payload.device}' command = f'{adb} shell monkey -p {payload.package_name} 1' - add_log(f'Runtime app restart requested: {payload.package_name}') result = run_command(command, cwd, timeout=120) add_log(f"Runtime app restart finished (exit {result['returncode']})") - - return { - 'success': result['returncode'] == 0, - 'package_name': payload.package_name, - 'device': payload.device, - 'result': result, - } + return {'success': result['returncode'] == 0, 'package_name': payload.package_name, 'device': payload.device, 'result': result}