Files
DevConsole/backend/runtime_api.py
T

216 строки
8.4 KiB
Python

import json
import os
import signal
import subprocess
import time
import uuid
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
from backend.runtime_logs import get_logs, add_log
from backend.shell_runner import run_command
router = APIRouter(prefix='/api/runtime', tags=['runtime'])
ACTIVE_PROCESSES: dict[str, subprocess.Popen] = {}
def _runtime_env_prefix() -> str:
runtime_home = os.getenv('DEVCONSOLE_RUNTIME_HOME') or os.getenv('HOME') or '/home/devconsole'
flutter_home = os.getenv('FLUTTER_HOME') or f'{runtime_home}/flutter'
android_home = os.getenv('ANDROID_HOME') or os.getenv('ANDROID_SDK_ROOT') or f'{runtime_home}/Android'
pub_cache = os.getenv('PUB_CACHE') or f'{runtime_home}/.pub-cache'
return f'export HOME="{runtime_home}" PUB_CACHE="{pub_cache}" ANDROID_HOME="{android_home}" ANDROID_SDK_ROOT="{android_home}" PATH="{flutter_home}/bin:{android_home}/cmdline-tools/latest/bin:{android_home}/platform-tools:$PATH" && '
def _flutter(command: str) -> str:
return f'{_runtime_env_prefix()}flutter {command}'
class RuntimeCommandRequest(BaseModel):
workspace: str
command: str
device: str | None = None
package_name: str | None = None
class RuntimeStopRequest(BaseModel):
task_id: str | None = None
send_confirm: bool = True
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},
}
def _workspace_cwd(workspace: str) -> str:
path = Path(workspace).resolve()
if not path.exists():
raise HTTPException(status_code=400, detail='Workspace path does not exist')
return path.as_posix()
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):
task_id = uuid.uuid4().hex
process = subprocess.Popen(
command,
cwd=cwd,
shell=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE,
bufsize=1,
preexec_fn=os.setsid,
)
ACTIVE_PROCESSES[task_id] = process
yield _event({'type': 'start', 'task_id': task_id, 'message': command})
started_at = time.time()
try:
assert process.stdout is not None
while True:
if time.time() - started_at > timeout:
yield _event({'type': 'error', 'task_id': task_id, 'message': 'Command timeout'})
break
line = process.stdout.readline()
if line:
yield _event({'type': 'line', 'task_id': task_id, 'message': line.rstrip()})
continue
if process.poll() is not None:
break
time.sleep(0.1)
returncode = process.poll()
if returncode is None:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
returncode = -1
finally:
ACTIVE_PROCESSES.pop(task_id, None)
yield _event({'type': 'done', 'task_id': task_id, 'returncode': returncode})
@router.get('/logs')
async def runtime_logs():
return {'success': True, 'logs': get_logs()}
@router.get('/commands')
async def runtime_commands():
return {'success': True, 'commands': [{'id': command_id, 'label': config['label'], 'needs_device': bool(config.get('device'))} for command_id, config in _runtime_commands().items()]}
@router.post('/event')
async def runtime_event(payload: dict):
message = payload.get('message', 'unknown runtime event')
add_log(message)
return {'success': True, 'message': message}
@router.post('/command')
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')))
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']})")
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')
@router.post('/stop')
async def runtime_stop(payload: RuntimeStopRequest):
targets = []
if payload.task_id:
process = ACTIVE_PROCESSES.get(payload.task_id)
if process:
targets.append((payload.task_id, process))
else:
targets = list(ACTIVE_PROCESSES.items())
stopped = []
for task_id, process in targets:
try:
if payload.send_confirm and process.stdin:
process.stdin.write('Y\n')
process.stdin.flush()
time.sleep(0.4)
if process.poll() is None:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
stopped.append(task_id)
except Exception as exc:
add_log(f'Runtime stop error: {exc}')
add_log(f'Runtime stop requested: {stopped}')
return {'success': True, 'stopped': stopped}
@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.')
adb = f'{_runtime_env_prefix()}adb'
if payload.device:
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}
@router.post('/restart-app')
async def runtime_restart_app(payload: RuntimeCommandRequest):
if not payload.package_name:
raise HTTPException(status_code=400, detail='package_name is required')
cwd = _workspace_cwd(payload.workspace)
adb = f'{_runtime_env_prefix()}adb'
if payload.device:
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}