import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { execFile } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import type { IncomingMessage, ServerResponse } from 'node:http'; const PROJECT_ROOT = process.cwd(); const APP_MAP_PATH = path.resolve(PROJECT_ROOT, 'public/data/telecomkz_app_map.json'); // Screen ids reach the shell as script arguments, so anything that is not a plain // slug is rejected outright rather than escaped. const SCREEN_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; const PYTHON_BIN = process.platform === 'win32' ? 'py' : 'python3'; interface ScriptResult { code: number; stdout: string; stderr: string; } function runPython(script: string, args: string[], timeoutMs: number): Promise { return new Promise(resolve => { execFile( PYTHON_BIN, [path.join('tools', script), ...args], { cwd: PROJECT_ROOT, timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => { const code = err && typeof (err as NodeJS.ErrnoException & { code?: number }).code === 'number' ? ((err as unknown as { code: number }).code) : err ? 1 : 0; resolve({ code, stdout: stdout || '', stderr: stderr || '' }); } ); }); } function sendJson(res: ServerResponse, status: number, body: unknown) { res.statusCode = status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); res.end(JSON.stringify(body)); } /** * The Python tools print exactly one JSON object on stdout as their contract. * Parse the last JSON-looking line so incidental logging cannot corrupt the reply. */ function parseToolOutput(stdout: string): Record | null { const lines = stdout.split(/\r?\n/).filter(l => l.trim().startsWith('{')); for (let i = lines.length - 1; i >= 0; i -= 1) { try { return JSON.parse(lines[i]); } catch { /* keep looking */ } } return null; } function readBody(req: IncomingMessage, limitBytes = 8 * 1024 * 1024): Promise { return new Promise((resolve, reject) => { let size = 0; const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => { size += chunk.length; if (size > limitBytes) { reject(new Error('Payload too large')); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); req.on('error', reject); }); } function requireScreenId(req: IncomingMessage): string { const url = new URL(req.url || '', 'http://localhost'); const screenId = url.searchParams.get('screenId') || 'main_dashboard'; if (!SCREEN_ID_RE.test(screenId)) { throw new Error(`Некорректный screenId: ${screenId}`); } return screenId; } export default defineConfig({ plugins: [ react(), { name: 'telecomkz-adb-bridge', configureServer(server) { // Serialise device work: ADB screencap, uiautomator and the CDP scroll driver // all mutate the same phone, and both scripts write the same app-map file. let deviceQueue: Promise = Promise.resolve(); const enqueue = (job: () => Promise): Promise => { const next = deviceQueue.then(job, job); deviceQueue = next.catch(() => undefined); return next; }; server.middlewares.use('/api/capture-adb', (req, res) => { let screenId: string; let name: string | null; let category: string; try { screenId = requireScreenId(req); const url = new URL(req.url || '', 'http://localhost'); name = url.searchParams.get('name'); category = url.searchParams.get('category') || 'Основное'; } catch (e) { sendJson(res, 400, { success: false, error: (e as Error).message }); return; } const args = [screenId, '--category', category]; if (name) args.push('--name', name); enqueue(async () => { server.config.logger.info(`[ADB Bridge] capture ${screenId}`); const out = await runPython('capture_screen.py', args, 180_000); const parsed = parseToolOutput(out.stdout); // Trust the script's own JSON verdict. Reading a leftover *_result.json // from an earlier run - as the previous version did - reports success for // a capture that never happened. if (!parsed || parsed.success !== true) { sendJson(res, 502, { success: false, error: (parsed && (parsed.error as string)) || out.stderr.trim() || `capture_screen.py завершился с кодом ${out.code}` }); return; } const resultPath = path.resolve( PROJECT_ROOT, `public/assets/screens/${screenId}_result.json` ); let full: Record = parsed; if (fs.existsSync(resultPath)) { try { full = JSON.parse(fs.readFileSync(resultPath, 'utf-8')); } catch (e) { server.config.logger.warn(`[ADB Bridge] unreadable result JSON: ${e}`); } } if (out.stderr.trim()) server.config.logger.info(`[ADB Bridge] ${out.stderr.trim()}`); sendJson(res, 200, full); }).catch(e => sendJson(res, 500, { success: false, error: String(e) })); }); server.middlewares.use('/api/live-sync-webview', (req, res) => { let screenId: string; try { screenId = requireScreenId(req); } catch (e) { sendJson(res, 400, { success: false, error: (e as Error).message }); return; } enqueue(async () => { const out = await runPython('live_auto_recorder.py', [screenId], 60_000); const parsed = parseToolOutput(out.stdout); if (!parsed || parsed.success !== true) { sendJson(res, 502, { success: false, error: (parsed && (parsed.error as string)) || out.stderr.trim() || `live_auto_recorder.py завершился с кодом ${out.code}` }); return; } let appMap: unknown = null; try { appMap = JSON.parse(fs.readFileSync(APP_MAP_PATH, 'utf-8')); } catch (e) { server.config.logger.warn(`[Live Sync] unreadable app map: ${e}`); } sendJson(res, 200, { ...parsed, appMap, timestamp: Date.now() }); }).catch(e => sendJson(res, 500, { success: false, error: String(e) })); }); // Persist edits made in the GUI editor. Without this the editor is a scratchpad: // every change is lost on reload unless the user remembers to export a file. server.middlewares.use('/api/save-app-map', (req, res) => { if (req.method !== 'POST') { sendJson(res, 405, { success: false, error: 'Используйте POST' }); return; } readBody(req) .then(raw => { const parsed = JSON.parse(raw); if (!parsed || !Array.isArray(parsed.screens)) { throw new Error('Ожидается объект с массивом screens'); } fs.mkdirSync(path.dirname(APP_MAP_PATH), { recursive: true }); const backup = `${APP_MAP_PATH}.bak`; if (fs.existsSync(APP_MAP_PATH)) fs.copyFileSync(APP_MAP_PATH, backup); const tmp = `${APP_MAP_PATH}.tmp`; fs.writeFileSync(tmp, JSON.stringify(parsed, null, 2), 'utf-8'); fs.renameSync(tmp, APP_MAP_PATH); sendJson(res, 200, { success: true, screens: parsed.screens.length, savedAt: Date.now() }); }) .catch(e => sendJson(res, 400, { success: false, error: (e as Error).message })); }); // Lets the UI tell "phone unplugged" apart from "server not running". server.middlewares.use('/api/device-status', (_req, res) => { enqueue(async () => { const out = await runPython('device_status.py', [], 45_000); const parsed = parseToolOutput(out.stdout); sendJson(res, 200, parsed || { connected: false, error: out.stderr.trim() }); }).catch(e => sendJson(res, 500, { connected: false, error: String(e) })); }); } } ] });