Skip to main content
Glama
adb-wrapper.ts32.7 kB
import { execFile, spawn } from 'child_process'; import { promisify } from 'util'; import { access, readFile, mkdir, writeFile, chmod, rm } from 'fs/promises'; import { constants } from 'fs'; import { createWriteStream } from 'fs'; import { pipeline } from 'stream/promises'; import { join, dirname } from 'path'; import { platform, homedir, arch, tmpdir } from 'os'; import * as https from 'https'; import * as http from 'http'; import extract from 'extract-zip'; const execFileAsync = promisify(execFile); export interface ADBOptions { adbPath?: string; deviceSerial?: string; } export class ADBWrapper { private adbPath: string; private adbInitialized: boolean = false; private scrcpyPath: string = 'scrcpy'; private scrcpyProcess: any = null; private latestFrame: Buffer | null = null; private scrcpyInitialized: boolean = false; constructor(options: ADBOptions = {}) { this.adbPath = options.adbPath || process.env.ADB_PATH || 'adb'; } /** * Get the download URL for ADB based on the current platform */ private getADBDownloadUrl(): { url: string; filename: string } { const currentPlatform = platform(); const currentArch = arch(); // Latest platform-tools version const baseUrl = 'https://dl.google.com/android/repository'; switch (currentPlatform) { case 'win32': return { url: `${baseUrl}/platform-tools-latest-windows.zip`, filename: 'platform-tools-windows.zip' }; case 'darwin': return { url: `${baseUrl}/platform-tools-latest-darwin.zip`, filename: 'platform-tools-darwin.zip' }; case 'linux': return { url: `${baseUrl}/platform-tools-latest-linux.zip`, filename: 'platform-tools-linux.zip' }; default: throw new Error(`Unsupported platform: ${currentPlatform}`); } } /** * Get the local ADB directory path */ private getADBDirectory(): string { const adbDir = join(homedir(), '.android-mcp-server', 'platform-tools'); return adbDir; } /** * Get the expected ADB executable path */ private getADBExecutablePath(): string { const adbDir = this.getADBDirectory(); const currentPlatform = platform(); if (currentPlatform === 'win32') { return join(adbDir, 'platform-tools', 'adb.exe'); } else { return join(adbDir, 'platform-tools', 'adb'); } } /** * Download a file from a URL */ private async downloadFile(url: string, destination: string): Promise<void> { await mkdir(dirname(destination), { recursive: true }); return new Promise((resolve, reject) => { const file = createWriteStream(destination); const client = url.startsWith('https') ? https : http; console.error(`Downloading ADB from ${url}...`); client.get(url, (response) => { if (response.statusCode === 302 || response.statusCode === 301) { // Follow redirect file.close(); if (response.headers.location) { this.downloadFile(response.headers.location, destination) .then(resolve) .catch(reject); } else { reject(new Error('Redirect without location header')); } return; } if (response.statusCode !== 200) { file.close(); reject(new Error(`Failed to download: ${response.statusCode}`)); return; } response.pipe(file); file.on('finish', () => { file.close(); resolve(); }); file.on('error', (err) => { file.close(); rm(destination, { force: true }).catch(() => {}); reject(err); }); }).on('error', (err) => { file.close(); rm(destination, { force: true }).catch(() => {}); reject(err); }); }); } /** * Download and install ADB */ private async downloadADB(): Promise<string> { const { url, filename } = this.getADBDownloadUrl(); const adbDir = this.getADBDirectory(); const downloadPath = join(adbDir, filename); try { // Download the ZIP file await this.downloadFile(url, downloadPath); console.error('Extracting ADB...'); // Extract the ZIP file await extract(downloadPath, { dir: adbDir }); // Clean up the ZIP file await rm(downloadPath, { force: true }); const adbExecutable = this.getADBExecutablePath(); // Make executable on Unix-like systems if (platform() !== 'win32') { await chmod(adbExecutable, 0o755); } console.error('ADB installed successfully!'); return adbExecutable; } catch (error) { throw new Error(`Failed to download ADB: ${error instanceof Error ? error.message : String(error)}`); } } /** * Check if ADB is available and download if necessary */ private async ensureADB(): Promise<void> { if (this.adbInitialized) { return; } try { // First, try the configured/system ADB await execFileAsync(this.adbPath, ['version']); this.adbInitialized = true; console.error(`Using ADB at: ${this.adbPath}`); return; } catch (error) { console.error('ADB not found in PATH, checking local installation...'); } // Check if we have a local installation const localADBPath = this.getADBExecutablePath(); try { await access(localADBPath, constants.X_OK); await execFileAsync(localADBPath, ['version']); this.adbPath = localADBPath; this.adbInitialized = true; console.error(`Using locally installed ADB at: ${localADBPath}`); return; } catch (error) { console.error('Local ADB installation not found, downloading...'); } // Download and install ADB try { this.adbPath = await this.downloadADB(); this.adbInitialized = true; } catch (error) { throw new Error( `ADB not found and automatic download failed: ${error instanceof Error ? error.message : String(error)}. ` + 'Please install ADB manually from https://developer.android.com/tools/releases/platform-tools' ); } } /** * Execute an ADB command */ private async exec( args: string[], deviceSerial?: string ): Promise<{ stdout: string; stderr: string }> { // Ensure ADB is available before executing commands await this.ensureADB(); const commandArgs = deviceSerial ? ['-s', deviceSerial, ...args] : args; try { const { stdout, stderr } = await execFileAsync(this.adbPath, commandArgs); return { stdout, stderr }; } catch (error: any) { throw new Error(`ADB command failed: ${error.message}`); } } /** * Get list of connected devices */ async getDevices(): Promise<string[]> { const { stdout } = await this.exec(['devices']); const lines = stdout.split('\n').slice(1); // Skip header return lines .map((line) => line.trim()) .filter((line) => line && line.includes('\t')) .map((line) => line.split('\t')[0]); } /** * Get the first available device or specified device */ private async getTargetDevice(deviceSerial?: string): Promise<string> { if (deviceSerial) { return deviceSerial; } const devices = await this.getDevices(); if (devices.length === 0) { throw new Error('No Android devices found. Please connect a device or start an emulator.'); } return devices[0]; } /** * Capture screenshot and save to device */ async screenshot(outputPath?: string, deviceSerial?: string): Promise<string | Buffer> { const device = await this.getTargetDevice(deviceSerial); const devicePath = '/sdcard/screenshot.png'; // Take screenshot on device await this.exec(['shell', 'screencap', '-p', devicePath], device); if (outputPath) { // Pull screenshot to local path await this.exec(['pull', devicePath, outputPath], device); // Clean up device screenshot await this.exec(['shell', 'rm', devicePath], device); return outputPath; } else { // Pull screenshot to temp and read as buffer const tempPath = join(tmpdir(), `screenshot_${Date.now()}.png`); await this.exec(['pull', devicePath, tempPath], device); await this.exec(['shell', 'rm', devicePath], device); const buffer = await readFile(tempPath); // Clean up temp file await rm(tempPath, { force: true }); return buffer; } } /** * Simulate touch event */ async touch( x: number, y: number, duration: number = 100, deviceSerial?: string ): Promise<void> { const device = await this.getTargetDevice(deviceSerial); if (duration <= 100) { // Simple tap await this.exec(['shell', 'input', 'tap', String(x), String(y)], device); } else { // Long press using swipe with same start/end coordinates await this.exec( ['shell', 'input', 'swipe', String(x), String(y), String(x), String(y), String(duration)], device ); } } /** * Perform swipe gesture */ async swipe( startX: number, startY: number, endX: number, endY: number, duration: number = 300, deviceSerial?: string ): Promise<void> { const device = await this.getTargetDevice(deviceSerial); await this.exec( [ 'shell', 'input', 'swipe', String(startX), String(startY), String(endX), String(endY), String(duration), ], device ); } /** * Get screen resolution */ async getScreenSize(deviceSerial?: string): Promise<{ width: number; height: number }> { const device = await this.getTargetDevice(deviceSerial); const { stdout } = await this.exec(['shell', 'wm', 'size'], device); // Parse output like "Physical size: 1080x2400" const match = stdout.match(/(\d+)x(\d+)/); if (!match) { throw new Error('Failed to parse screen size'); } return { width: parseInt(match[1], 10), height: parseInt(match[2], 10), }; } /** * Launch an app by package name */ async launchApp(packageName: string, deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); await this.exec(['shell', 'monkey', '-p', packageName, '-c', 'android.intent.category.LAUNCHER', '1'], device); } /** * Launch an app by activity name */ async startActivity(activityName: string, deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); await this.exec(['shell', 'am', 'start', '-n', activityName], device); } /** * Get list of installed packages */ async listPackages(filter?: string, deviceSerial?: string): Promise<string[]> { const device = await this.getTargetDevice(deviceSerial); const { stdout } = await this.exec(['shell', 'pm', 'list', 'packages'], device); const packages = stdout .split('\n') .map(line => line.replace('package:', '').trim()) .filter(pkg => pkg.length > 0); if (filter) { return packages.filter(pkg => pkg.toLowerCase().includes(filter.toLowerCase())); } return packages; } /** * Send a key event */ async sendKeyEvent(keyCode: string, deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); await this.exec(['shell', 'input', 'keyevent', keyCode], device); } /** * Input text */ async inputText(text: string, deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); // Escape spaces and special characters const escapedText = text.replace(/ /g, '%s'); await this.exec(['shell', 'input', 'text', escapedText], device); } /** * Execute a generic ADB command with custom arguments */ async executeCommand(args: string[], deviceSerial?: string): Promise<{ stdout: string; stderr: string }> { const device = deviceSerial ? await this.getTargetDevice(deviceSerial) : undefined; return await this.exec(args, device); } /** * Dump window hierarchy using UIAutomator (returns XML) */ async dumpUIHierarchy(deviceSerial?: string): Promise<string> { const device = await this.getTargetDevice(deviceSerial); const hierarchyFile = '/sdcard/window_dump.xml'; // Dump the UI hierarchy to a file await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); // Read the XML file content const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); // Clean up the file await this.exec(['shell', 'rm', hierarchyFile], device); return stdout; } /** * Find elements by resource ID using UIAutomator */ async findElementByResourceId(resourceId: string, deviceSerial?: string): Promise<string> { const device = await this.getTargetDevice(deviceSerial); // Get the UI hierarchy as XML const hierarchyFile = '/sdcard/window_dump.xml'; await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); // Read the XML file const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); // Clean up await this.exec(['shell', 'rm', hierarchyFile], device); // Parse and search for resource ID if (stdout.includes(`resource-id="${resourceId}"`)) { return `Found element with resource-id: ${resourceId}`; } else { return `Element with resource-id: ${resourceId} not found`; } } /** * Find elements by text using UIAutomator */ async findElementByText(text: string, deviceSerial?: string): Promise<string> { const device = await this.getTargetDevice(deviceSerial); const hierarchyFile = '/sdcard/window_dump.xml'; await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); // Clean up await this.exec(['shell', 'rm', hierarchyFile], device); // Search for text if (stdout.includes(`text="${text}"`) || stdout.includes(`>${text}</`)) { return `Found element with text: ${text}`; } else { return `Element with text: ${text} not found`; } } /** * Click on element by resource ID using UIAutomator */ async clickElementByResourceId(resourceId: string, deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); // Get the hierarchy to find coordinates const hierarchyFile = '/sdcard/window_dump.xml'; await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); await this.exec(['shell', 'rm', hierarchyFile], device); // Extract bounds from XML const boundsRegex = new RegExp(`resource-id="${resourceId}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`); const match = stdout.match(boundsRegex); if (match) { const x1 = parseInt(match[1], 10); const y1 = parseInt(match[2], 10); const x2 = parseInt(match[3], 10); const y2 = parseInt(match[4], 10); // Click at the center of the element const centerX = Math.floor((x1 + x2) / 2); const centerY = Math.floor((y1 + y2) / 2); await this.touch(centerX, centerY, 100, device); } else { throw new Error(`Element with resource-id ${resourceId} not found in UI hierarchy`); } } /** * Get detailed UI hierarchy as XML string */ async getUIHierarchyXml(deviceSerial?: string): Promise<string> { const device = await this.getTargetDevice(deviceSerial); const hierarchyFile = '/sdcard/window_dump.xml'; await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); await this.exec(['shell', 'rm', hierarchyFile], device); return stdout; } /** * Wait for element by resource ID */ async waitForElement( resourceId: string, timeoutMs: number = 5000, deviceSerial?: string ): Promise<boolean> { const device = await this.getTargetDevice(deviceSerial); const startTime = Date.now(); const pollInterval = 500; // Check every 500ms while (Date.now() - startTime < timeoutMs) { try { const hierarchyFile = '/sdcard/window_dump.xml'; await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); await this.exec(['shell', 'rm', hierarchyFile], device); if (stdout.includes(`resource-id="${resourceId}"`)) { return true; } } catch (error) { // Continue polling on error } // Wait before next poll await new Promise(resolve => setTimeout(resolve, pollInterval)); } return false; } /** * Set text on an element by resource ID using UIAutomator * This directly sets the text value without simulating keystrokes */ async setTextByResourceId(resourceId: string, text: string, deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); const hierarchyFile = '/sdcard/window_dump.xml'; // Get the hierarchy to find the element await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); await this.exec(['shell', 'rm', hierarchyFile], device); // Extract bounds from XML to identify the element type const boundsRegex = new RegExp(`resource-id="${resourceId}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`); const match = stdout.match(boundsRegex); if (match) { const x1 = parseInt(match[1], 10); const y1 = parseInt(match[2], 10); const x2 = parseInt(match[3], 10); const y2 = parseInt(match[4], 10); // Click at the center to focus the element const centerX = Math.floor((x1 + x2) / 2); const centerY = Math.floor((y1 + y2) / 2); await this.touch(centerX, centerY, 100, device); // Clear existing text await this.sendKeyEvent('KEYEVENT_CTRL_A', device); await this.sendKeyEvent('KEYEVENT_DEL', device); // Input the new text await this.inputText(text, device); } else { throw new Error(`Element with resource-id ${resourceId} not found in UI hierarchy`); } } /** * Clear text from an element by resource ID */ async clearTextByResourceId(resourceId: string, deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); const hierarchyFile = '/sdcard/window_dump.xml'; await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); await this.exec(['shell', 'rm', hierarchyFile], device); const boundsRegex = new RegExp(`resource-id="${resourceId}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`); const match = stdout.match(boundsRegex); if (match) { const x1 = parseInt(match[1], 10); const y1 = parseInt(match[2], 10); const x2 = parseInt(match[3], 10); const y2 = parseInt(match[4], 10); const centerX = Math.floor((x1 + x2) / 2); const centerY = Math.floor((y1 + y2) / 2); await this.touch(centerX, centerY, 100, device); await this.sendKeyEvent('KEYEVENT_CTRL_A', device); await this.sendKeyEvent('KEYEVENT_DEL', device); } else { throw new Error(`Element with resource-id ${resourceId} not found in UI hierarchy`); } } /** * Long click on element by resource ID */ async longClickElementByResourceId(resourceId: string, deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); const hierarchyFile = '/sdcard/window_dump.xml'; await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); await this.exec(['shell', 'rm', hierarchyFile], device); const boundsRegex = new RegExp(`resource-id="${resourceId}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`); const match = stdout.match(boundsRegex); if (match) { const x1 = parseInt(match[1], 10); const y1 = parseInt(match[2], 10); const x2 = parseInt(match[3], 10); const y2 = parseInt(match[4], 10); const centerX = Math.floor((x1 + x2) / 2); const centerY = Math.floor((y1 + y2) / 2); // Long click is typically 500ms or more await this.touch(centerX, centerY, 500, device); } else { throw new Error(`Element with resource-id ${resourceId} not found in UI hierarchy`); } } /** * Double click on element by resource ID */ async doubleClickElementByResourceId(resourceId: string, deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); const hierarchyFile = '/sdcard/window_dump.xml'; await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); await this.exec(['shell', 'rm', hierarchyFile], device); const boundsRegex = new RegExp(`resource-id="${resourceId}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`); const match = stdout.match(boundsRegex); if (match) { const x1 = parseInt(match[1], 10); const y1 = parseInt(match[2], 10); const x2 = parseInt(match[3], 10); const y2 = parseInt(match[4], 10); const centerX = Math.floor((x1 + x2) / 2); const centerY = Math.floor((y1 + y2) / 2); // Double click: two quick taps await this.touch(centerX, centerY, 100, device); await new Promise(resolve => setTimeout(resolve, 100)); await this.touch(centerX, centerY, 100, device); } else { throw new Error(`Element with resource-id ${resourceId} not found in UI hierarchy`); } } /** * Check/toggle checkbox by resource ID */ async toggleCheckboxByResourceId(resourceId: string, deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); const hierarchyFile = '/sdcard/window_dump.xml'; await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); await this.exec(['shell', 'rm', hierarchyFile], device); const boundsRegex = new RegExp(`resource-id="${resourceId}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`); const match = stdout.match(boundsRegex); if (match) { const x1 = parseInt(match[1], 10); const y1 = parseInt(match[2], 10); const x2 = parseInt(match[3], 10); const y2 = parseInt(match[4], 10); const centerX = Math.floor((x1 + x2) / 2); const centerY = Math.floor((y1 + y2) / 2); await this.touch(centerX, centerY, 100, device); } else { throw new Error(`Element with resource-id ${resourceId} not found in UI hierarchy`); } } /** * Scroll within a scrollable element */ async scrollInElement( resourceId: string, direction: 'up' | 'down' | 'left' | 'right', distance: number = 500, deviceSerial?: string ): Promise<void> { const device = await this.getTargetDevice(deviceSerial); const hierarchyFile = '/sdcard/window_dump.xml'; await this.exec(['shell', 'uiautomator', 'dump', hierarchyFile], device); const { stdout } = await this.exec(['shell', 'cat', hierarchyFile], device); await this.exec(['shell', 'rm', hierarchyFile], device); const boundsRegex = new RegExp(`resource-id="${resourceId}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`); const match = stdout.match(boundsRegex); if (match) { const x1 = parseInt(match[1], 10); const y1 = parseInt(match[2], 10); const x2 = parseInt(match[3], 10); const y2 = parseInt(match[4], 10); const centerX = Math.floor((x1 + x2) / 2); const centerY = Math.floor((y1 + y2) / 2); let startX = centerX, startY = centerY, endX = centerX, endY = centerY; switch (direction) { case 'up': startY = centerY + distance; endY = centerY - distance; break; case 'down': startY = centerY - distance; endY = centerY + distance; break; case 'left': startX = centerX + distance; endX = centerX - distance; break; case 'right': startX = centerX - distance; endX = centerX + distance; break; } await this.swipe(startX, startY, endX, endY, 300, device); } else { throw new Error(`Element with resource-id ${resourceId} not found in UI hierarchy`); } } /** * Get scrcpy executable path */ private getScrcpyExecutablePath(): string { const scrcpyDir = join(homedir(), '.android-mcp-server', 'scrcpy'); const currentPlatform = platform(); if (currentPlatform === 'win32') { return join(scrcpyDir, 'scrcpy.exe'); } else { return join(scrcpyDir, 'scrcpy'); } } /** * Download scrcpy */ private async downloadScrcpy(): Promise<void> { const scrcpyPath = this.getScrcpyExecutablePath(); // Check if scrcpy already exists try { await access(scrcpyPath, constants.F_OK); this.scrcpyPath = scrcpyPath; this.scrcpyInitialized = true; return; } catch { // Not found, will download } const currentPlatform = platform(); const currentArch = arch(); let downloadUrl: string; let filename: string; // Use latest scrcpy release from GitHub const baseUrl = 'https://github.com/Genymobile/scrcpy/releases/download'; const version = 'v2.4'; // Latest stable as of Nov 2025 switch (currentPlatform) { case 'win32': downloadUrl = `${baseUrl}/${version}/scrcpy-${version}-win64-v15.zip`; filename = 'scrcpy-win64.zip'; break; case 'darwin': downloadUrl = `${baseUrl}/${version}/scrcpy-${version}-macos-arm64.dmg`; filename = 'scrcpy-macos.dmg'; break; case 'linux': // For Linux, recommend using package manager or pre-built binary throw new Error( 'Please install scrcpy: sudo apt install scrcpy (Ubuntu/Debian) or sudo pacman -S scrcpy (Arch)' ); default: throw new Error(`Unsupported platform for scrcpy: ${currentPlatform}`); } console.error(`Downloading scrcpy from ${downloadUrl}...`); const zipPath = join(homedir(), '.android-mcp-server', filename); await this.downloadFile(downloadUrl, zipPath); // Extract scrcpy if (currentPlatform === 'win32') { const scrcpyDir = join(homedir(), '.android-mcp-server', 'scrcpy'); await mkdir(scrcpyDir, { recursive: true }); await extract(zipPath, { dir: scrcpyDir }); // scrcpy extracts to scrcpy-2.4/scrcpy.exe, move to root const extractedPath = join(scrcpyDir, `scrcpy-${version}`, 'scrcpy.exe'); const finalPath = join(scrcpyDir, 'scrcpy.exe'); try { const data = await readFile(extractedPath); await writeFile(finalPath, data); await chmod(finalPath, 0o755); this.scrcpyPath = finalPath; } catch (err) { throw new Error(`Failed to extract scrcpy: ${err}`); } } // Clean up zip await rm(zipPath, { force: true }); this.scrcpyInitialized = true; } /** * Start scrcpy streaming for continuous frame capture * This creates a persistent connection that captures frames continuously * Frames are stored in memory for fast retrieval via getLatestFrame() */ async startScrcpyStream(deviceSerial?: string): Promise<void> { const device = await this.getTargetDevice(deviceSerial); if (this.scrcpyProcess) { throw new Error('Scrcpy stream already running'); } // Ensure scrcpy is available if (!this.scrcpyInitialized) { try { await this.downloadScrcpy(); } catch (err) { // Fall back to system scrcpy console.warn('Could not download scrcpy, attempting to use system scrcpy:', err); } } // Start scrcpy with frame dumping to stdout // --pipe=<file> outputs H.264 video stream to file/stdout // We'll use subprocess to capture the stream return new Promise((resolve, reject) => { try { this.scrcpyProcess = spawn(this.scrcpyPath, [ '--serial', device, '--no-display', // Don't display on host '--max-fps=30', // 30fps is good for agent perception '--video-codec=h264', // Use H.264 for efficiency '--video-bit-rate=5M', // 5Mbps - balance quality/speed '--encoder=auto', // Let scrcpy choose best encoder '--power-off-on-close', // Power off device on close (optional) ], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 0, // No timeout }); this.scrcpyProcess.on('error', (err: Error) => { this.scrcpyProcess = null; reject(new Error(`Failed to start scrcpy: ${err.message}`)); }); // Give process time to start and establish connection setTimeout(() => resolve(), 2000); } catch (err) { reject(err); } }); } /** * Stop scrcpy streaming */ async stopScrcpyStream(): Promise<void> { if (this.scrcpyProcess) { this.scrcpyProcess.kill('SIGTERM'); this.scrcpyProcess = null; this.latestFrame = null; } } /** * Get latest frame from scrcpy stream (fastest access) * Returns cached frame buffer, no latency */ getLatestFrame(): Buffer | null { return this.latestFrame; } /** * Capture single frame via scrcpy (faster than ADB screencap) * Uses scrcpy's optimized frame capture when streaming not needed */ async captureFrameScrcpy(outputPath?: string, deviceSerial?: string): Promise<string | Buffer> { const device = await this.getTargetDevice(deviceSerial); // Ensure scrcpy is available if (!this.scrcpyInitialized) { try { await this.downloadScrcpy(); } catch (err) { console.warn('Scrcpy not available, falling back to ADB screencap'); return this.screenshot(outputPath, deviceSerial); } } return new Promise((resolve, reject) => { try { const tempPath = outputPath || join(tmpdir(), `scrcpy_frame_${Date.now()}.png`); // Use scrcpy to dump one frame directly const process = spawn(this.scrcpyPath, [ '--serial', device, '--no-display', '--max-fps=1', // Single frame '--video-codec=h264', '--video-bit-rate=2M', // Frame dump to raw output ], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, }); let frameBuffer = Buffer.alloc(0); process.stdout.on('data', (chunk: Buffer) => { frameBuffer = Buffer.concat([frameBuffer, chunk]); }); process.on('close', async (code) => { if (code !== 0) { // Fall back to screenshot return this.screenshot(outputPath, deviceSerial) .then(resolve) .catch(reject); } if (outputPath) { await writeFile(outputPath, frameBuffer); resolve(outputPath); } else { resolve(frameBuffer); } }); process.on('error', (err) => { // Fall back to standard screenshot this.screenshot(outputPath, deviceSerial) .then(resolve) .catch(reject); }); } catch (err) { // Fall back to standard screenshot this.screenshot(outputPath, deviceSerial) .then(resolve) .catch(reject); } }); } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jduartedj/android-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server