Добавил API ручной Flutter команды

Этот коммит содержится в:
Виктор
2026-05-12 02:23:41 +09:00
родитель 9dc13fe034
Коммит e4406d3aaa
+147
Просмотреть файл
@@ -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',
)