Skip to main content
Glama
TakeScreenshot.ts13.8 kB
import fs from "fs-extra"; import path from "path"; import { AdbUtils } from "../../utils/android-cmdline-tools/adb"; import { Window } from "./Window"; import { logger } from "../../utils/logger"; import { ScreenshotResult } from "../../models/ScreenshotResult"; import { Image } from "../../utils/image-utils"; import { BootedDevice } from "../../models"; export interface ScreenshotOptions { format?: "png" | "webp"; quality?: number; lossless?: boolean; } export class TakeScreenshot { private readonly device: BootedDevice; private adb: AdbUtils; private window: Window; private static cacheDir: string = path.join("/tmp/auto-mobile", "screenshots"); private static readonly MAX_CACHE_SIZE_BYTES = 128 * 1024 * 1024; // 128MB /** * Create a TakeScreenshot instance * @param device - Optional device * @param adb - Optional AdbUtils instance for testing */ constructor( device: BootedDevice, adb: AdbUtils | null = null, ) { this.device = device; this.adb = adb || new AdbUtils(device); this.window = new Window(device, this.adb); // Ensure cache directory exists if (!fs.existsSync(TakeScreenshot.cacheDir)) { fs.mkdirSync(TakeScreenshot.cacheDir, { recursive: true }); } // Manage cache size this.cleanupCache(); } /** * Clean up the cache directory if it exceeds the maximum size */ private async cleanupCache(): Promise<void> { try { if (!fs.existsSync(TakeScreenshot.cacheDir)) {return;} // Get all files in cache with their stats const files = await fs.readdir(TakeScreenshot.cacheDir); const fileStats = await Promise.all( files.map(async file => { const filePath = path.join(TakeScreenshot.cacheDir, file); const stats = await fs.stat(filePath); return { path: filePath, stats, mtime: stats.mtime.getTime() }; }) ); // Calculate total size const totalSize = fileStats.reduce((sum, file) => sum + file.stats.size, 0); // If we're over the limit, remove oldest files until under limit if (totalSize > TakeScreenshot.MAX_CACHE_SIZE_BYTES) { // Sort by modification time (oldest first) fileStats.sort((a, b) => a.mtime - b.mtime); let currentSize = totalSize; for (const file of fileStats) { if (currentSize <= TakeScreenshot.MAX_CACHE_SIZE_BYTES) {break;} await fs.unlink(file.path); currentSize -= file.stats.size; logger.debug(`Removed cached screenshot: ${file.path}`); } } } catch (err) { logger.warn("Failed to cleanup screenshot cache:", err); } } /** * Generate screenshot file path * @param timestamp - Timestamp for unique filename * @param options - Screenshot options * @returns Full file path for screenshot */ generateScreenshotPath(timestamp: number, options: ScreenshotOptions): string { const fileExtension = options.format === "webp" ? "webp" : "png"; return path.join(TakeScreenshot.cacheDir, `screenshot_${timestamp}.${fileExtension}`); } /** * Get activity hash for screenshot naming * @param activityHash - Optional provided hash * @returns Promise with activity hash */ public async getActivityHash(activityHash: string | null): Promise<string> { return !activityHash ? await this.window.getActiveHash() : activityHash; } /** * Take a screenshot of the device * @param options - Optional screenshot format options * @returns Promise with screenshot result including success status and path if successful */ async execute( options: ScreenshotOptions = { format: "png" } ): Promise<ScreenshotResult> { const startTime = Date.now(); logger.info(`[SCREENSHOT] *** Starting screenshot capture with startTime: ${startTime}, format: ${options.format} ***`); try { // Generate unique filename with startTime const finalPath = this.generateScreenshotPath(startTime, options); // Capture screenshot with fallback const captureResult = await this.captureScreenshot(finalPath, options); const totalDuration = Date.now() - startTime; logger.info(`[SCREENSHOT] *** Screenshot capture completed: success=${captureResult.success}, total execute time: ${totalDuration}ms ***`); return captureResult; } catch (err) { const totalDuration = Date.now() - startTime; const errorMessage = err instanceof Error ? err.message : String(err); logger.warn(`[SCREENSHOT] Execute failed after ${totalDuration}ms: ${errorMessage}`); return { success: false, error: `Failed to take screenshot: ${errorMessage}` }; } } /** * Capture screenshot using screencap method with fallback * @param finalPath - Path to save the screenshot * @param options - Screenshot format options * @returns ScreenshotResult with path to the saved screenshot or error */ private async captureScreenshot( finalPath: string, options: ScreenshotOptions = { format: "png" } ): Promise<ScreenshotResult> { logger.info(`[SCREENSHOT] Starting screenshot capture with format: ${options.format}`); switch (this.device.platform) { case "android": return await this.captureAndroidScreenshot(finalPath, options); case "ios": return await this.captureiOSScreenshot(finalPath); default: throw new Error(`Unsupported platform: ${this.device.platform}`); } } /** * Capture screenshot using screencap method with fallback * @param finalPath - Path to save the screenshot * @param options - Screenshot format options * @returns ScreenshotResult with path to the saved screenshot or error */ private async captureAndroidScreenshot( finalPath: string, options: ScreenshotOptions = { format: "png" } ): Promise<ScreenshotResult> { logger.info(`[SCREENSHOT] Starting screenshot capture with format: ${options.format}`); // Try base64 approach first (faster for smaller screenshots) try { return await this.captureScreenshotBase64(finalPath, options); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); if (errorMessage.includes("maxBuffer") || errorMessage.includes("stdout") || errorMessage.includes("buffer")) { logger.info(`[SCREENSHOT] Base64 approach failed (${errorMessage}), falling back to file pull approach`); return await this.captureScreenshotFilePull(finalPath, options); } else { // For other errors, don't fallback throw err; } } } /** * Capture screenshot using screencap method with fallback * @param finalPath - Path to save the screenshot * @returns ScreenshotResult with path to the saved screenshot or error */ private async captureiOSScreenshot( finalPath: string, ): Promise<ScreenshotResult> { return { success: true, path: finalPath, } as ScreenshotResult; } /** * Capture screenshot using base64 encoding (faster but may hit buffer limits) * @param finalPath - Path to save the screenshot * @param options - Screenshot format options * @returns ScreenshotResult with path to the saved screenshot or error */ private async captureScreenshotBase64( finalPath: string, options: ScreenshotOptions = { format: "png" } ): Promise<ScreenshotResult> { const startTime = Date.now(); logger.info(`[SCREENSHOT] Trying base64 approach`); const cmdStartTime = Date.now(); const tempFile = "/sdcard/screenshot.png"; // Single command: screencap -> base64 encode -> remove temp file const command = `shell "screencap -p ${tempFile} && base64 ${tempFile} && rm ${tempFile}"`; // Use larger maxBuffer (50MB) to handle high-resolution screenshots const maxBuffer = 50 * 1024 * 1024; // 50MB const result = await this.adb.executeCommand(command, undefined, maxBuffer); const cmdDuration = Date.now() - cmdStartTime; logger.info(`[SCREENSHOT] Combined ADB command took ${cmdDuration}ms`); if (!result.stdout || result.stdout.trim().length === 0) { throw new Error("No base64 data received from screencap command"); } // Decode base64 data to buffer const decodeStartTime = Date.now(); const cleanedOutput = result.stdout.replace(/[\r\n]/g, ""); const imageBuffer = Buffer.from(cleanedOutput, "base64"); const decodeDuration = Date.now() - decodeStartTime; logger.info(`[SCREENSHOT] Base64 decode took ${decodeDuration}ms, buffer size: ${imageBuffer.length} bytes`); // Handle format conversion and save if (options.format !== "webp") { // For PNG, save directly const saveStartTime = Date.now(); await fs.writeFile(finalPath, imageBuffer); const saveDuration = Date.now() - saveStartTime; logger.info(`[SCREENSHOT] PNG file save took ${saveDuration}ms`); } else { // Convert to WebP const convertStartTime = Date.now(); const image = Image.fromBuffer(imageBuffer); const transformer = image.webp({ quality: options.quality || 75, lossless: options.lossless }); const convertedImage = await transformer.toBuffer(); const convertDuration = Date.now() - convertStartTime; logger.info(`[SCREENSHOT] WebP conversion took ${convertDuration}ms`); // Save the webp file const saveStartTime = Date.now(); await fs.writeFile(finalPath, convertedImage); const saveDuration = Date.now() - saveStartTime; logger.info(`[SCREENSHOT] WebP file save took ${saveDuration}ms`); } const totalDuration = Date.now() - startTime; logger.info(`[SCREENSHOT] Base64 screenshot capture completed in ${totalDuration}ms`); return { success: true, path: finalPath }; } /** * Capture screenshot using file pull approach (more reliable for large screenshots) * @param finalPath - Path to save the screenshot * @param options - Screenshot format options * @returns ScreenshotResult with path to the saved screenshot or error */ private async captureScreenshotFilePull( finalPath: string, options: ScreenshotOptions = { format: "png" } ): Promise<ScreenshotResult> { const startTime = Date.now(); logger.info(`[SCREENSHOT] Using file pull approach`); try { // Use file pull approach instead of base64 to avoid stdout buffer issues const cmdStartTime = Date.now(); const tempFile = "/sdcard/screenshot.png"; const tempLocalFile = `${finalPath}.temp`; // Step 1: Take screenshot on device const screencapResult = await this.adb.executeCommand(`shell screencap -p ${tempFile}`); if (screencapResult.stderr && screencapResult.stderr.includes("error")) { throw new Error(`Screencap failed: ${screencapResult.stderr}`); } // Step 2: Pull file from device to local filesystem const pullResult = await this.adb.executeCommand(`pull ${tempFile} ${tempLocalFile}`); if (pullResult.stderr && pullResult.stderr.includes("error")) { throw new Error(`Failed to pull screenshot: ${pullResult.stderr}`); } // Step 3: Clean up temp file on device await this.adb.executeCommand(`shell rm ${tempFile}`); const cmdDuration = Date.now() - cmdStartTime; logger.info(`[SCREENSHOT] Screenshot capture and pull took ${cmdDuration}ms`); // Step 4: Read the pulled file into buffer const readStartTime = Date.now(); const imageBuffer = await fs.readFile(tempLocalFile); const readDuration = Date.now() - readStartTime; logger.info(`[SCREENSHOT] File read took ${readDuration}ms, buffer size: ${imageBuffer.length} bytes`); // Step 5: Handle format conversion and save to final path if (options.format !== "webp") { // For PNG, move the temp file to final path const saveStartTime = Date.now(); await fs.move(tempLocalFile, finalPath); const saveDuration = Date.now() - saveStartTime; logger.info(`[SCREENSHOT] PNG file move took ${saveDuration}ms`); } else { // Convert to WebP const convertStartTime = Date.now(); const image = Image.fromBuffer(imageBuffer); const transformer = image.webp({ quality: options.quality || 75, lossless: options.lossless }); const convertedImage = await transformer.toBuffer(); const convertDuration = Date.now() - convertStartTime; logger.info(`[SCREENSHOT] WebP conversion took ${convertDuration}ms`); // Save the webp file and remove temp file const saveStartTime = Date.now(); await fs.writeFile(finalPath, convertedImage); await fs.remove(tempLocalFile); const saveDuration = Date.now() - saveStartTime; logger.info(`[SCREENSHOT] WebP file save took ${saveDuration}ms`); } const totalDuration = Date.now() - startTime; logger.info(`[SCREENSHOT] File pull screenshot capture completed in ${totalDuration}ms`); return { success: true, path: finalPath }; } catch (err) { const totalDuration = Date.now() - startTime; const errorMessage = err instanceof Error ? err.message : String(err); logger.warn(`[SCREENSHOT] File pull screenshot capture failed after ${totalDuration}ms: ${errorMessage}`); // Clean up any temp files try { const tempLocalFile = `${finalPath}.temp`; if (await fs.pathExists(tempLocalFile)) { await fs.remove(tempLocalFile); } } catch (cleanupErr) { logger.debug(`Failed to cleanup temp file: ${cleanupErr}`); } throw err; } } }

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/zillow/auto-mobile'

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