UNPKG

videokitten

Version:

A cross-platform Node.js library for recording videos from iOS simulators and Android devices/emulators

721 lines (703 loc) 22.6 kB
// src/utils/error-handling.ts function doHandleError(handler, error) { if (!handler || handler === "throw") { throw error; } if (handler === "ignore") { return; } handler(error); } // src/utils/file-paths.ts import crypto from "node:crypto"; import path from "node:path"; import os from "node:os"; import fs from "node:fs"; function ensureFileDirectory(filePath) { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } function createVideoPath(platform, extension, outputPath) { if (!outputPath) { const filename = generateVideoFilename(platform, extension); return path.join(os.tmpdir(), filename); } const parsedPath = path.parse(outputPath); const hasExtension = parsedPath.ext.length > 0; if (hasExtension) { return outputPath; } else { const filename = generateVideoFilename(platform, extension); return path.join(outputPath, filename); } } function generateVideoFilename(platform, extension) { const timestamp = Date.now(); const uuid = crypto.randomUUID().slice(0, 8); return `${platform}-video-${timestamp}-${uuid}.${extension}`; } // src/utils/timeout-signal.ts function createTimeoutSignal(userSignal, timeoutMs) { if (!timeoutMs && !userSignal) { return { signal: new AbortController().signal, cleanup: () => { } }; } if (!timeoutMs && userSignal) { return { signal: userSignal, cleanup: () => { } }; } if (timeoutMs && !userSignal) { const controller2 = new AbortController(); const timeoutId2 = setTimeout(() => { controller2.abort(new Error(`Recording timed out after ${timeoutMs}ms`)); }, timeoutMs); return { signal: controller2.signal, cleanup: () => clearTimeout(timeoutId2) }; } const controller = new AbortController(); let timeoutId; if (userSignal.aborted) { controller.abort(userSignal.reason); } else { const onUserAbort = () => { controller.abort(userSignal.reason); }; userSignal.addEventListener("abort", onUserAbort); timeoutId = setTimeout(() => { controller.abort(new Error(`Recording timed out after ${timeoutMs}ms`)); }, timeoutMs); controller.signal.addEventListener("abort", () => { userSignal.removeEventListener("abort", onUserAbort); }); } return { signal: controller.signal, cleanup: () => { if (timeoutId) { clearTimeout(timeoutId); } } }; } // src/utils/process.ts import { spawn } from "node:child_process"; var RecordingProcess = class { constructor(options) { this.options = options; var _a, _b; this.signal = options.signal; this.child = spawn(options.command, options.args, { env: options.env, stdio: ["ignore", "pipe", "pipe"] }); (_a = this.child.stderr) == null ? void 0 : _a.on("data", (chunk) => { this.stderrBuffer += chunk.toString(); }); this.child.on("exit", (code) => { this.processExited = true; this.exitCode = code; }); this.child.on("error", (err) => { this.processExited = true; this.exitError = err; }); (_b = this.signal) == null ? void 0 : _b.addEventListener("abort", this.onAbort); } child; signal; stderrBuffer = ""; processExited = false; exitCode = null; exitError; async started() { await this.waitForProcessReady(); await this.sleep(this.startupDelay); } async stop() { await this.sleep(this.stopDelay); await this.stopProcess(); } onAbort = () => { if (!this.processExited) { try { this.child.kill("SIGINT"); } catch { } } }; get startupDelay() { return Array.isArray(this.options.delay) ? this.options.delay[0] : this.options.delay ?? 0; } get stopDelay() { return Array.isArray(this.options.delay) ? this.options.delay[1] : this.options.delay ?? 0; } sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async waitForProcessReady() { return await new Promise((resolve, reject) => { var _a, _b, _c; if (this.processExited) { return reject(this.getExitError()); } const readyMatcher = this.options.readyMatcher; let resolved = false; const resolveOnce = () => { var _a2, _b2; if (!resolved) { resolved = true; (_a2 = this.child.stdout) == null ? void 0 : _a2.removeListener("data", onData); (_b2 = this.child.stderr) == null ? void 0 : _b2.removeListener("data", onData); this.child.removeListener("spawn", resolveOnce); this.child.removeListener("exit", onExit); this.child.removeListener("error", onExit); resolve(); } }; const onData = (chunk) => { if (readyMatcher(chunk.toString())) { resolveOnce(); } }; const onExit = () => { if (!resolved) { reject(this.getExitError()); } }; if (readyMatcher) { (_a = this.child.stdout) == null ? void 0 : _a.on("data", onData); (_b = this.child.stderr) == null ? void 0 : _b.on("data", onData); } else { this.child.once("spawn", resolveOnce); } this.child.once("exit", onExit); this.child.once("error", onExit); if ((_c = this.signal) == null ? void 0 : _c.aborted) { reject(new Error("Operation was aborted.")); } }); } async stopProcess() { return await new Promise((resolve, reject) => { if (this.processExited) { if (this.exitError) { return reject(this.exitError); } else if (this.exitCode !== 0 && this.exitCode !== null) { return reject(this.getExitError()); } return resolve(); } this.child.once("exit", () => { var _a; (_a = this.signal) == null ? void 0 : _a.removeEventListener("abort", this.onAbort); resolve(); }); this.child.once("error", (err) => { var _a; (_a = this.signal) == null ? void 0 : _a.removeEventListener("abort", this.onAbort); reject(err); }); try { this.child.kill("SIGINT"); } catch (error) { reject(error); } }); } getExitError() { var _a; if (this.exitError) { return this.exitError; } if ((_a = this.signal) == null ? void 0 : _a.aborted) { return new Error("Operation was aborted."); } const message = this.stderrBuffer.trim() || `Process exited with code ${this.exitCode || "unknown"}`; return new Error(message); } }; // src/session.ts import fs2 from "node:fs"; // src/errors.ts var VideokittenError = class extends Error { constructor(message, options) { super(message, options); this.name = "VideokittenError"; } }; var VideokittenDeviceNotFoundError = class extends VideokittenError { constructor(deviceId, cause) { super(`Device not found: ${deviceId}`, { cause }); this.name = "VideokittenDeviceNotFoundError"; } }; var VideokittenXcrunNotFoundError = class extends VideokittenError { constructor(xcrunPath, cause) { super(`xcrun not found at path: ${xcrunPath}`, { cause }); this.name = "VideokittenXcrunNotFoundError"; } }; var VideokittenAdbNotFoundError = class extends VideokittenError { constructor(adbPath, cause) { const troubleshooting = ` To fix ADB issues: 1. Check if Android SDK is installed: \u2022 Android Studio: Check SDK Manager for Android SDK Platform-Tools \u2022 Command line: Look for ANDROID_HOME or ANDROID_SDK_ROOT environment variables 2. Add ADB to your PATH: \u2022 macOS/Linux: export PATH="$ANDROID_HOME/platform-tools:$PATH" \u2022 Windows: Add %ANDROID_HOME%\\platform-tools to your PATH 3. Use adbPath option: \u2022 videokitten({ platform: 'android', adbPath: '/path/to/adb' }) \u2022 Common locations: - macOS: ~/Library/Android/sdk/platform-tools/adb - Linux: ~/Android/Sdk/platform-tools/adb - Windows: %USERPROFILE%\\AppData\\Local\\Android\\Sdk\\platform-tools\\adb.exe 4. Install Android SDK Platform-Tools: \u2022 Via Android Studio SDK Manager \u2022 Standalone: https://developer.android.com/studio/releases/platform-tools`; super(`adb not found at path: ${adbPath}${troubleshooting}`, { cause }); this.name = "VideokittenAdbNotFoundError"; } }; var VideokittenScrcpyNotFoundError = class extends VideokittenError { constructor(scrcpyPath, cause) { const installInstructions = ` To install scrcpy: \u2022 macOS: brew install scrcpy \u2022 Linux: apt install scrcpy (Ubuntu/Debian) or snap install scrcpy \u2022 Windows: winget install scrcpy or download from GitHub releases \u2022 From source: https://github.com/Genymobile/scrcpy Make sure scrcpy is in your PATH or provide the full path via scrcpyPath option.`; super(`scrcpy not found at path: ${scrcpyPath}${installInstructions}`, { cause }); this.name = "VideokittenScrcpyNotFoundError"; } }; var VideokittenIOSSimulatorError = class extends VideokittenError { constructor(deviceId, cause) { super(`iOS Simulator not available or not booted: ${deviceId}`, { cause }); this.name = "VideokittenIOSSimulatorError"; } }; var VideokittenAndroidDeviceError = class extends VideokittenError { constructor(deviceId, cause) { super(`Android device/emulator not available: ${deviceId}`, { cause }); this.name = "VideokittenAndroidDeviceError"; } }; var VideokittenFileWriteError = class extends VideokittenError { constructor(outputPath, cause) { super(`Failed to write video file: ${outputPath}`, { cause }); this.name = "VideokittenFileWriteError"; } }; var VideokittenOperationAbortedError = class extends VideokittenError { constructor(operation = "operation") { super(`${operation} was aborted`); this.name = "VideokittenOperationAbortedError"; } }; var VideokittenRecordingFailedError = class extends VideokittenError { constructor(platform, cause) { super(`${platform.toUpperCase()} video recording command failed`, { cause }); this.name = "VideokittenRecordingFailedError"; } }; // src/session.ts var RecordingSession = class { constructor(process2, videoPath, onError) { this.process = process2; this.videoPath = videoPath; this.onError = onError; } async stop() { try { await this.process.stop(); if (!fs2.existsSync(this.videoPath)) { throw new VideokittenFileWriteError( this.videoPath, new Error("Video file was not created") ); } return this.videoPath; } catch (error) { doHandleError(this.onError, error); return; } } }; // src/options/ios.ts function createIOSOptions(options = {}) { const args = ["simctl", "io"]; if (options.deviceId === void 0) { args.push("booted"); } else { args.push(options.deviceId); } args.push("recordVideo"); if (options.codec) { args.push("--codec", options.codec); } if (options.display) { args.push("--display", options.display); } if (options.mask) { args.push("--mask", options.mask); } if (options.force) { args.push("--force"); } if (options.outputPath !== void 0) { args.push(options.outputPath); } return args; } // src/options/android.ts function createAndroidOptions(options = {}) { const args = []; if (options.deviceId) { args.push("--serial", options.deviceId); } if (options.outputPath) { args.push("--record", options.outputPath); } if (options.timeout !== void 0) { args.push("--time-limit", options.timeout.toString()); } if (options.tunnel) { if (options.tunnel.host) { args.push("--tunnel-host", options.tunnel.host); } if (options.tunnel.port !== void 0) { args.push("--tunnel-port", options.tunnel.port.toString()); } } if (options.window) { if (options.window.enabled === false) { args.push("--no-window"); } if (options.window.borderless) { args.push("--window-borderless"); } if (options.window.title) { args.push("--window-title", options.window.title); } if (options.window.x !== void 0) { args.push("--window-x", options.window.x.toString()); } if (options.window.y !== void 0) { args.push("--window-y", options.window.y.toString()); } if (options.window.width !== void 0) { args.push("--window-width", options.window.width.toString()); } if (options.window.height !== void 0) { args.push("--window-height", options.window.height.toString()); } if (options.window.alwaysOnTop) { args.push("--always-on-top"); } if (options.window.fullscreen) { args.push("--fullscreen"); } } else if (options.window === false) { args.push("--no-window"); } if (options.recording) { if (options.recording.bitRate !== void 0) { args.push("--video-bit-rate", options.recording.bitRate.toString()); } if (options.recording.codec) { args.push("--video-codec", options.recording.codec); } if (options.recording.format) { args.push("--record-format", options.recording.format); } if (options.recording.maxSize !== void 0) { args.push("--max-size", options.recording.maxSize.toString()); } if (options.recording.crop) { const { width, height, x, y } = options.recording.crop; args.push("--crop", `${width}:${height}:${x}:${y}`); } if (options.recording.orientation !== void 0) { args.push("--record-orientation", options.recording.orientation.toString()); } if (options.recording.timeLimit !== void 0 && options.timeout === void 0) { args.push("--time-limit", options.recording.timeLimit.toString()); } } if (options.audio) { if (options.audio.enabled === false) { args.push("--no-audio"); } if (options.audio.source) { args.push("--audio-source", options.audio.source); } if (options.audio.codec) { args.push("--audio-codec", options.audio.codec); } if (options.audio.bitRate !== void 0) { args.push("--audio-bit-rate", options.audio.bitRate.toString()); } if (options.audio.buffer !== void 0) { args.push("--audio-buffer", options.audio.buffer.toString()); } if (options.audio.encoder) { args.push("--audio-encoder", options.audio.encoder); } } else if (options.audio === false) { args.push("--no-audio"); } if (options.debug) { if (options.debug.showTouches) { args.push("--show-touches"); } if (options.debug.logLevel) { args.push("--verbosity", options.debug.logLevel); } if (options.debug.printFps) { args.push("--print-fps"); } } if (options.input) { if (options.input.keyboard) { args.push("--keyboard", options.input.keyboard); } if (options.input.mouse) { args.push("--mouse", options.input.mouse); } if (options.input.rawKeyEvents) { args.push("--raw-key-events"); } if (options.input.preferText) { args.push("--prefer-text"); } if (options.input.shortcutModifiers && options.input.shortcutModifiers.length > 0) { args.push("--shortcut-mod", options.input.shortcutModifiers.join(",")); } } if (options.app) { if (options.app.startApp) { const prefix = options.app.forceStop ? "+" : ""; args.push("--start-app", `${prefix}${options.app.startApp}`); } if (options.app.newDisplay) { if (typeof options.app.newDisplay === "boolean" && options.app.newDisplay) { args.push("--new-display"); } else if (typeof options.app.newDisplay === "object") { let displayValue = ""; if (options.app.newDisplay.width && options.app.newDisplay.height) { displayValue = `${options.app.newDisplay.width}x${options.app.newDisplay.height}`; } if (options.app.newDisplay.dpi) { displayValue += `/${options.app.newDisplay.dpi}`; } if (displayValue) { args.push("--new-display", displayValue); } else { args.push("--new-display"); } } } } if (options.screen) { if (options.screen.turnOff) { args.push("--turn-screen-off"); } if (options.screen.timeout !== void 0) { args.push("--screen-off-timeout", options.screen.timeout.toString()); } } if (options.advanced) { if (options.advanced.renderDriver) { args.push("--render-driver", options.advanced.renderDriver); } if (options.advanced.videoSource) { args.push("--video-source", options.advanced.videoSource); } if (options.advanced.otg) { args.push("--otg"); } if (options.advanced.stayAwake) { args.push("--stay-awake"); } if (options.advanced.noPowerOn) { args.push("--no-power-on"); } if (options.advanced.killAdbOnClose) { args.push("--kill-adb-on-close"); } if (options.advanced.powerOffOnClose) { args.push("--power-off-on-close"); } if (options.advanced.disableScreensaver) { args.push("--disable-screensaver"); } } return args; } // src/ios.ts var VideokittenIOS = class { xcrunPath; options; constructor(options) { this.options = options; this.xcrunPath = options.xcrunPath || "/usr/bin/xcrun"; } async startRecording(overrideOptions = {}) { const options = { ...this.options, ...overrideOptions }; const onError = options.onError || "throw"; const expectedPath = createVideoPath("ios", "mp4", options.outputPath); const timeoutMs = options.timeout ? options.timeout * 1e3 : void 0; const { signal, cleanup } = createTimeoutSignal( options.abortSignal, timeoutMs ); try { ensureFileDirectory(expectedPath); const args = createIOSOptions({ ...options, outputPath: expectedPath }); const process2 = new RecordingProcess({ command: this.xcrunPath, args, signal, readyMatcher: (data) => data.includes("Recording started"), delay: options.delay }); await process2.started(); return new RecordingSession(process2, expectedPath, onError); } catch (error) { const recordingError = this._classifyError( error, options.deviceId || "booted" ); doHandleError(onError, recordingError); return; } finally { cleanup(); } } _classifyError(error, deviceId) { if (error instanceof Error) { if (error.message.includes("ENOENT") || error.message.includes("command not found")) { return new VideokittenXcrunNotFoundError(this.xcrunPath, error); } else if (error.message.includes("Invalid device") || error.message.includes("device not found")) { return new VideokittenIOSSimulatorError(deviceId, error); } else if (error.message.includes("aborted") || error.name === "AbortError") { return new VideokittenOperationAbortedError("iOS video recording"); } else { return new VideokittenRecordingFailedError("ios", error); } } else { return new VideokittenRecordingFailedError("ios"); } } }; // src/android.ts function getFileExtension(format) { return format || "mp4"; } var VideokittenAndroid = class { scrcpyPath; options; constructor(options) { this.options = { audio: false, window: false, ...options }; this.scrcpyPath = options.scrcpyPath || "scrcpy"; } async startRecording(overrideOptions = {}) { var _a, _b, _c; const options = { ...this.options, ...overrideOptions }; const onError = options.onError || "throw"; const extension = getFileExtension((_a = options.recording) == null ? void 0 : _a.format); const expectedPath = createVideoPath( "android", extension, options.outputPath ); try { if ((_b = options.abortSignal) == null ? void 0 : _b.aborted) { throw new VideokittenOperationAbortedError("Android video recording"); } ensureFileDirectory(expectedPath); const args = createAndroidOptions({ ...options, outputPath: expectedPath }); const env = { ...process.env }; if (options.adbPath) { env.ADB = options.adbPath; } const logLevel = (_c = options.debug) == null ? void 0 : _c.logLevel; const recordingProcess = new RecordingProcess({ command: this.scrcpyPath, args, env, signal: options.abortSignal, delay: options.delay ?? 200, readyMatcher: logLevel !== "warn" && logLevel !== "error" ? (data) => data.includes("Device:") : void 0 }); await recordingProcess.started(); return new RecordingSession(recordingProcess, expectedPath, onError); } catch (error) { const recordingError = this._classifyError( error, options.deviceId || "default", expectedPath ); doHandleError(onError, recordingError); return; } } _classifyError(error, deviceId, outputPath) { if (error instanceof Error) { if (error.message.includes("ENOENT") || error.message.includes("command not found")) { return new VideokittenScrcpyNotFoundError(this.scrcpyPath, error); } else if (error.message.includes("device not found") || error.message.includes("device offline")) { return new VideokittenAndroidDeviceError(deviceId, error); } else if (error.message.includes("aborted") || error.name === "AbortError") { return new VideokittenOperationAbortedError("Android video recording"); } else if (error.code === "ENOENT" || error.code === "EACCES") { return new VideokittenFileWriteError(outputPath, error); } else { return new VideokittenRecordingFailedError("android", error); } } else { return new VideokittenRecordingFailedError("android"); } } }; // src/index.ts function videokitten(options) { switch (options.platform) { case "ios": { return new VideokittenIOS(options); } case "android": { return new VideokittenAndroid(options); } default: { throw new Error(`Unsupported platform: ${options.platform}`); } } } export { VideokittenAdbNotFoundError, VideokittenAndroidDeviceError, VideokittenDeviceNotFoundError, VideokittenError, VideokittenFileWriteError, VideokittenIOSSimulatorError, VideokittenOperationAbortedError, VideokittenRecordingFailedError, VideokittenScrcpyNotFoundError, VideokittenXcrunNotFoundError, videokitten }; //# sourceMappingURL=index.mjs.map