Добавил API ручной Flutter команды
Этот коммит содержится в:
@@ -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',
|
||||||
|
)
|
||||||
Ссылка в новой задаче
Block a user