diff --git a/backend/flutter_manual_api.py b/backend/flutter_manual_api.py new file mode 100644 index 0000000..e031192 --- /dev/null +++ b/backend/flutter_manual_api.py @@ -0,0 +1,147 @@ +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.runtime_logs import add_log + +router = APIRouter(prefix='/api/runtime', tags=['runtime-flutter-manual']) +ACTIVE_MANUAL_FLUTTER: dict[str, subprocess.Popen] = {} + +ALLOWED_FIRST_WORDS = { + 'analyze', + 'assemble', + 'attach', + 'build', + 'channel', + 'clean', + 'config', + 'devices', + 'doctor', + 'drive', + 'emulators', + 'gen-l10n', + 'logs', + 'packages', + 'precache', + 'pub', + 'run', + 'screenshot', + 'test', + 'upgrade', + 'version', +} + + +class ManualFlutterRequest(BaseModel): + workspace: str + args: str + device: str | None = None + timeout: int | None = 3600 + + +def _runtime_env() -> dict: + 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' + env = os.environ.copy() + env.update({ + 'HOME': runtime_home, + 'PUB_CACHE': pub_cache, + 'ANDROID_HOME': android_home, + 'ANDROID_SDK_ROOT': android_home, + 'PATH': f'{flutter_home}/bin:{android_home}/cmdline-tools/latest/bin:{android_home}/platform-tools:' + env.get('PATH', ''), + }) + return env + + +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 _split_args(raw_args: str) -> list[str]: + cleaned = (raw_args or '').strip() + if not cleaned: + raise HTTPException(status_code=400, detail='Flutter command is empty') + if cleaned.startswith('flutter '): + cleaned = cleaned[len('flutter '):].strip() + if any(ch in cleaned for ch in ['\n', '\r', '\x00']): + raise HTTPException(status_code=400, detail='Invalid Flutter command') + parts = cleaned.split() + if not parts: + raise HTTPException(status_code=400, detail='Flutter command is empty') + if parts[0] not in ALLOWED_FIRST_WORDS: + raise HTTPException(status_code=400, detail='Unsupported Flutter subcommand') + return parts + + +def _event(payload: dict) -> str: + return json.dumps(payload, ensure_ascii=False) + '\n' + + +def _stream_manual_flutter(workspace: str, args: str, device: str | None, timeout: int): + cwd = _workspace_cwd(workspace) + parts = _split_args(args) + if device and '-d' not in parts and '--device-id' not in parts: + parts.extend(['-d', device]) + command = ['flutter', *parts] + task_id = uuid.uuid4().hex + + process = subprocess.Popen( + command, + cwd=cwd, + env=_runtime_env(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + bufsize=1, + preexec_fn=os.setsid, + ) + ACTIVE_MANUAL_FLUTTER[task_id] = process + add_log('Manual Flutter command started: ' + ' '.join(command)) + yield _event({'type': 'start', 'task_id': task_id, 'message': ' '.join(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': 'Manual Flutter 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_MANUAL_FLUTTER.pop(task_id, None) + + add_log('Manual Flutter command finished: ' + ' '.join(command) + f' exit {returncode}') + yield _event({'type': 'done', 'task_id': task_id, 'returncode': returncode}) + + +@router.post('/flutter-manual-stream') +async def flutter_manual_stream(payload: ManualFlutterRequest): + timeout = max(30, min(int(payload.timeout or 3600), 7200)) + return StreamingResponse( + _stream_manual_flutter(payload.workspace, payload.args, payload.device, timeout), + media_type='application/x-ndjson', + )