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
JavaScript
// 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