UNPKG

node-mac-recorder

Version:

Native macOS screen recording package for Node.js applications

959 lines (855 loc) 27 kB
const { EventEmitter } = require("events"); const path = require("path"); const fs = require("fs"); // Native modülü yükle let nativeBinding; try { // Prefer prebuild on arm64 if (process.platform === "darwin" && process.arch === "arm64") { nativeBinding = require("./prebuilds/darwin-arm64/node.napi.node"); } else { nativeBinding = require("./build/Release/mac_recorder.node"); } } catch (error) { try { nativeBinding = require("./build/Release/mac_recorder.node"); } catch (_) { try { nativeBinding = require("./build/Debug/mac_recorder.node"); } catch (debugError) { throw new Error( 'Native module not found. Please run "npm run build" to compile the native module.\n' + "Original error: " + error.message ); } } } class MacRecorder extends EventEmitter { constructor() { super(); this.isRecording = false; this.outputPath = null; this.recordingTimer = null; this.recordingStartTime = null; // Cursor capture variables this.cursorCaptureInterval = null; this.cursorCaptureFile = null; this.cursorCaptureStartTime = null; this.cursorCaptureFirstWrite = true; this.lastCapturedData = null; this.cursorDisplayInfo = null; this.recordingDisplayInfo = null; this.options = { includeMicrophone: false, // Default olarak mikrofon kapalı includeSystemAudio: true, // Default olarak sistem sesi açık quality: "medium", frameRate: 30, captureArea: null, // { x, y, width, height } captureCursor: false, // Default olarak cursor gizli showClicks: false, displayId: null, // Hangi ekranı kaydedeceği (null = ana ekran) windowId: null, // Hangi pencereyi kaydedeceği (null = tam ekran) }; // Display cache için async initialization this.cachedDisplays = null; this.refreshDisplayCache(); // Native cursor warm-up (cold start delay'ini önlemek için) this.warmUpCursor(); } /** * macOS ses cihazlarını listeler */ async getAudioDevices() { return new Promise((resolve, reject) => { try { const devices = nativeBinding.getAudioDevices(); const formattedDevices = devices.map((device) => ({ name: typeof device === "string" ? device : device.name || device, id: typeof device === "object" ? device.id : device, type: typeof device === "object" ? device.type : "Audio Device", })); resolve(formattedDevices); } catch (error) { reject(error); } }); } /** * macOS ekranlarını listeler */ async getDisplays() { const displays = nativeBinding.getDisplays(); return displays.map((display, index) => ({ id: display.id, // Use the actual display ID from native code name: display.name, width: display.width, height: display.height, x: display.x, y: display.y, isPrimary: display.isPrimary, resolution: `${display.width}x${display.height}`, })); } /** * macOS açık pencerelerini listeler */ async getWindows() { return new Promise((resolve, reject) => { try { const windows = nativeBinding.getWindows(); resolve(windows); } catch (error) { reject(error); } }); } /** * Kayıt seçeneklerini ayarlar */ setOptions(options = {}) { this.options = { includeMicrophone: options.includeMicrophone || false, includeSystemAudio: options.includeSystemAudio !== false, // Default true unless explicitly disabled captureCursor: options.captureCursor || false, displayId: options.displayId || null, // null = ana ekran windowId: options.windowId || null, // null = tam ekran audioDeviceId: options.audioDeviceId || null, // null = default device systemAudioDeviceId: options.systemAudioDeviceId || null, // null = auto-detect system audio device captureArea: options.captureArea || null, // Exclusion options excludeCurrentApp: options.excludeCurrentApp || false, excludeWindowIds: Array.isArray(options.excludeWindowIds) ? options.excludeWindowIds : [], }; } /** * Ekran kaydını başlatır (macOS native AVFoundation kullanarak) */ async startRecording(outputPath, options = {}) { if (this.isRecording) { throw new Error("Recording is already in progress"); } if (!outputPath) { throw new Error("Output path is required"); } // Seçenekleri güncelle this.setOptions(options); // WindowId varsa captureArea'yı otomatik ayarla if (this.options.windowId && !this.options.captureArea) { try { const windows = await this.getWindows(); const displays = await this.getDisplays(); const targetWindow = windows.find( (w) => w.id === this.options.windowId ); if (targetWindow) { // Pencere hangi display'de olduğunu bul let targetDisplayId = null; let adjustedX = targetWindow.x; let adjustedY = targetWindow.y; // Pencere hangi display'de? for (let i = 0; i < displays.length; i++) { const display = displays[i]; const displayWidth = parseInt(display.resolution.split("x")[0]); const displayHeight = parseInt(display.resolution.split("x")[1]); // Pencere bu display sınırları içinde mi? if ( targetWindow.x >= display.x && targetWindow.x < display.x + displayWidth && targetWindow.y >= display.y && targetWindow.y < display.y + displayHeight ) { targetDisplayId = display.id; // Use actual display ID, not array index // Koordinatları display'e göre normalize et adjustedX = targetWindow.x - display.x; // Y coordinate conversion: CGWindow (top-left) to AVFoundation (bottom-left) // Overlay'deki dönüşümle aynı mantık: screenHeight - windowY - windowHeight const displayHeight = parseInt(display.resolution.split("x")[1]); const convertedY = displayHeight - targetWindow.y - targetWindow.height; adjustedY = Math.max(0, convertedY - display.y); break; } } // Eğer display bulunamadıysa ana display kullan if (targetDisplayId === null) { const mainDisplay = displays.find((d) => d.x === 0 && d.y === 0); if (mainDisplay) { targetDisplayId = mainDisplay.id; // Use actual display ID, not array index adjustedX = Math.max( 0, Math.min( targetWindow.x, parseInt(mainDisplay.resolution.split("x")[0]) - targetWindow.width ) ); adjustedY = Math.max( 0, Math.min( targetWindow.y, parseInt(mainDisplay.resolution.split("x")[1]) - targetWindow.height ) ); } } // DisplayId'yi ayarla if (targetDisplayId !== null) { this.options.displayId = targetDisplayId; // Recording için display bilgisini sakla (cursor capture için) const targetDisplay = displays.find( (d) => d.id === targetDisplayId ); this.recordingDisplayInfo = { displayId: targetDisplayId, x: targetDisplay.x, y: targetDisplay.y, width: parseInt(targetDisplay.resolution.split("x")[0]), height: parseInt(targetDisplay.resolution.split("x")[1]), }; } this.options.captureArea = { x: Math.max(0, adjustedX), y: Math.max(0, adjustedY), width: targetWindow.width, height: targetWindow.height, }; console.log( `Window ${targetWindow.appName}: display=${targetDisplayId}, coords=${targetWindow.x},${targetWindow.y} -> ${adjustedX},${adjustedY}` ); } } catch (error) { console.warn( "Pencere bilgisi alınamadı, tam ekran kaydedilecek:", error.message ); } } // DisplayId manuel ayarlanmışsa display bilgisini sakla if (this.options.displayId !== null && !this.recordingDisplayInfo) { try { const displays = await this.getDisplays(); const targetDisplay = displays.find( (d) => d.id === this.options.displayId ); if (targetDisplay) { this.recordingDisplayInfo = { displayId: this.options.displayId, x: targetDisplay.x, y: targetDisplay.y, width: parseInt(targetDisplay.resolution.split("x")[0]), height: parseInt(targetDisplay.resolution.split("x")[1]), }; } } catch (error) { console.warn("Display bilgisi alınamadı:", error.message); } } // Çıkış dizinini oluştur const outputDir = path.dirname(outputPath); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } this.outputPath = outputPath; return new Promise((resolve, reject) => { try { // Native kayıt başlat const recordingOptions = { includeMicrophone: this.options.includeMicrophone || false, includeSystemAudio: this.options.includeSystemAudio !== false, // Default true unless explicitly disabled captureCursor: this.options.captureCursor || false, displayId: this.options.displayId || null, // null = ana ekran windowId: this.options.windowId || null, // null = tam ekran audioDeviceId: this.options.audioDeviceId || null, // null = default device systemAudioDeviceId: this.options.systemAudioDeviceId || null, // null = auto-detect system audio device // Exclusion options passthrough excludeCurrentApp: this.options.excludeCurrentApp || false, excludeWindowIds: this.options.excludeWindowIds || [], }; // Manuel captureArea varsa onu kullan if (this.options.captureArea) { recordingOptions.captureArea = { x: this.options.captureArea.x, y: this.options.captureArea.y, width: this.options.captureArea.width, height: this.options.captureArea.height, }; } const success = nativeBinding.startRecording( outputPath, recordingOptions ); if (success) { this.isRecording = true; this.recordingStartTime = Date.now(); // Timer başlat (progress tracking için) this.recordingTimer = setInterval(() => { const elapsed = Math.floor( (Date.now() - this.recordingStartTime) / 1000 ); this.emit("timeUpdate", elapsed); }, 1000); // Native kayıt gerçekten başladığını kontrol etmek için polling başlat let recordingStartedEmitted = false; const checkRecordingStatus = setInterval(() => { try { const nativeStatus = nativeBinding.getRecordingStatus(); if (nativeStatus && !recordingStartedEmitted) { recordingStartedEmitted = true; clearInterval(checkRecordingStatus); // Kayıt gerçekten başladığı anda event emit et this.emit("recordingStarted", { outputPath: this.outputPath, timestamp: Date.now(), // Gerçek başlangıç zamanı options: this.options, nativeConfirmed: true, }); } } catch (error) { // Native status check error - fallback if (!recordingStartedEmitted) { recordingStartedEmitted = true; clearInterval(checkRecordingStatus); this.emit("recordingStarted", { outputPath: this.outputPath, timestamp: this.recordingStartTime, options: this.options, nativeConfirmed: false, }); } } }, 50); // Her 50ms kontrol et // Timeout fallback - 5 saniye sonra hala başlamamışsa emit et setTimeout(() => { if (!recordingStartedEmitted) { recordingStartedEmitted = true; clearInterval(checkRecordingStatus); this.emit("recordingStarted", { outputPath: this.outputPath, timestamp: this.recordingStartTime, options: this.options, nativeConfirmed: false, }); } }, 5000); this.emit("started", this.outputPath); resolve(this.outputPath); } else { reject( new Error( "Failed to start recording. Check permissions and try again." ) ); } } catch (error) { reject(error); } }); } /** * Ekran kaydını durdurur */ async stopRecording() { if (!this.isRecording) { throw new Error("No recording in progress"); } return new Promise((resolve, reject) => { try { const success = nativeBinding.stopRecording(); // Timer durdur if (this.recordingTimer) { clearInterval(this.recordingTimer); this.recordingTimer = null; } this.isRecording = false; this.recordingDisplayInfo = null; const result = { code: success ? 0 : 1, outputPath: this.outputPath, }; this.emit("stopped", result); if (success) { // Dosyanın oluşturulmasını bekle setTimeout(() => { if (fs.existsSync(this.outputPath)) { this.emit("completed", this.outputPath); } }, 1000); } resolve(result); } catch (error) { this.isRecording = false; this.recordingDisplayInfo = null; if (this.recordingTimer) { clearInterval(this.recordingTimer); this.recordingTimer = null; } reject(error); } }); } /** * Kayıt durumunu döndürür */ getStatus() { const nativeStatus = nativeBinding.getRecordingStatus(); return { isRecording: this.isRecording && nativeStatus, outputPath: this.outputPath, options: this.options, recordingTime: this.recordingStartTime ? Math.floor((Date.now() - this.recordingStartTime) / 1000) : 0, }; } /** * macOS'ta kayıt izinlerini kontrol eder */ async checkPermissions() { return new Promise((resolve) => { try { const hasPermission = nativeBinding.checkPermissions(); resolve({ screenRecording: hasPermission, accessibility: hasPermission, microphone: hasPermission, // Native modül ses izinlerini de kontrol ediyor }); } catch (error) { resolve({ screenRecording: false, accessibility: false, microphone: false, error: error.message, }); } }); } /** * Pencere önizleme görüntüsü alır (Base64 PNG) */ async getWindowThumbnail(windowId, options = {}) { if (!windowId) { throw new Error("Window ID is required"); } const { maxWidth = 300, maxHeight = 200 } = options; return new Promise((resolve, reject) => { try { const base64Image = nativeBinding.getWindowThumbnail( windowId, maxWidth, maxHeight ); if (base64Image) { resolve(`data:image/png;base64,${base64Image}`); } else { reject(new Error("Failed to capture window thumbnail")); } } catch (error) { reject(error); } }); } /** * Ekran önizleme görüntüsü alır (Base64 PNG) */ async getDisplayThumbnail(displayId, options = {}) { if (displayId === null || displayId === undefined) { throw new Error("Display ID is required"); } const { maxWidth = 300, maxHeight = 200 } = options; return new Promise((resolve, reject) => { try { // Get all displays first to validate the ID const displays = nativeBinding.getDisplays(); const display = displays.find((d) => d.id === displayId); if (!display) { throw new Error(`Display with ID ${displayId} not found`); } const base64Image = nativeBinding.getDisplayThumbnail( display.id, // Use the actual CGDirectDisplayID maxWidth, maxHeight ); if (base64Image) { resolve(`data:image/png;base64,${base64Image}`); } else { reject(new Error("Failed to capture display thumbnail")); } } catch (error) { reject(error); } }); } /** * Event'in kaydedilip kaydedilmeyeceğini belirler */ shouldCaptureEvent(currentData) { if (!this.lastCapturedData) { return true; // İlk event } const last = this.lastCapturedData; // Event type değişmişse if (currentData.type !== last.type) { return true; } // Pozisyon değişmişse (minimum 2 pixel tolerans) if ( Math.abs(currentData.x - last.x) >= 2 || Math.abs(currentData.y - last.y) >= 2 ) { return true; } // Cursor type değişmişse if (currentData.cursorType !== last.cursorType) { return true; } // Hiçbir değişiklik yoksa kaydetme return false; } /** * Cursor capture başlatır - otomatik olarak dosyaya yazmaya başlar * Recording başlatılmışsa otomatik olarak display-relative koordinatlar kullanır * @param {string|number} intervalOrFilepath - Cursor data JSON dosya yolu veya interval * @param {Object} options - Cursor capture seçenekleri * @param {Object} options.windowInfo - Pencere bilgileri (window-relative koordinatlar için) * @param {boolean} options.windowRelative - Koordinatları pencereye göre relative yap */ async startCursorCapture(intervalOrFilepath = 100, options = {}) { let filepath; let interval = 20; // Default 50 FPS // Parameter parsing: number = interval, string = filepath if (typeof intervalOrFilepath === "number") { interval = Math.max(10, intervalOrFilepath); // Min 10ms filepath = `cursor-data-${Date.now()}.json`; } else if (typeof intervalOrFilepath === "string") { filepath = intervalOrFilepath; } else { throw new Error( "Parameter must be interval (number) or filepath (string)" ); } if (this.cursorCaptureInterval) { throw new Error("Cursor capture is already running"); } // Koordinat sistemi belirle: window-relative, display-relative veya global if (options.windowRelative && options.windowInfo) { // Window-relative koordinatlar için pencere bilgilerini kullan // Cursor pozisyonu için Y dönüşümü YAPMA - sadece window offset'ini çıkar this.cursorDisplayInfo = { displayId: options.windowInfo.displayId || null, x: options.windowInfo.x || 0, y: options.windowInfo.y || 0, width: options.windowInfo.width, height: options.windowInfo.height, windowRelative: true, windowInfo: options.windowInfo, }; } else if (this.recordingDisplayInfo) { // Recording başlatılmışsa o display'i kullan this.cursorDisplayInfo = this.recordingDisplayInfo; } else { // Main display bilgisini al (her zaman relative koordinatlar için) try { const displays = await this.getDisplays(); const mainDisplay = displays.find((d) => d.isPrimary) || displays[0]; if (mainDisplay) { this.cursorDisplayInfo = { displayId: 0, x: mainDisplay.x, y: mainDisplay.y, width: parseInt(mainDisplay.resolution.split("x")[0]), height: parseInt(mainDisplay.resolution.split("x")[1]), }; } } catch (error) { console.warn("Main display bilgisi alınamadı:", error.message); this.cursorDisplayInfo = null; // Fallback: global koordinatlar } } return new Promise((resolve, reject) => { try { // Dosyayı oluştur ve temizle const fs = require("fs"); fs.writeFileSync(filepath, "["); this.cursorCaptureFile = filepath; this.cursorCaptureStartTime = Date.now(); this.cursorCaptureFirstWrite = true; this.lastCapturedData = null; // JavaScript interval ile polling yap (daha sık - mouse event'leri yakalamak için) this.cursorCaptureInterval = setInterval(() => { try { const position = nativeBinding.getCursorPosition(); const timestamp = Date.now() - this.cursorCaptureStartTime; // Global koordinatları relative koordinatlara çevir let x = position.x; let y = position.y; let coordinateSystem = "global"; if (this.cursorDisplayInfo) { // Offset'leri çıkar (display veya window) // Y koordinat dönüşümü başlangıçta yapıldı x = position.x - this.cursorDisplayInfo.x; y = position.y - this.cursorDisplayInfo.y; if (this.cursorDisplayInfo.windowRelative) { // Window-relative koordinatlar coordinateSystem = "window-relative"; // Window bounds kontrolü - cursor window dışındaysa kaydetme if ( x < 0 || y < 0 || x >= this.cursorDisplayInfo.width || y >= this.cursorDisplayInfo.height ) { return; // Bu frame'i skip et - cursor pencere dışında } } else { // Display-relative koordinatlar coordinateSystem = "display-relative"; // Display bounds kontrolü if ( x < 0 || y < 0 || x >= this.cursorDisplayInfo.width || y >= this.cursorDisplayInfo.height ) { return; // Bu frame'i skip et - cursor display dışında } } } const cursorData = { x: x, y: y, timestamp: timestamp, unixTimeMs: Date.now(), cursorType: position.cursorType, type: position.eventType || "move", coordinateSystem: coordinateSystem, ...(this.cursorDisplayInfo?.windowRelative && { windowInfo: { width: this.cursorDisplayInfo.width, height: this.cursorDisplayInfo.height, originalWindow: this.cursorDisplayInfo.windowInfo, }, }), }; // Sadece eventType değiştiğinde veya pozisyon değiştiğinde kaydet if (this.shouldCaptureEvent(cursorData)) { // Dosyaya ekle const jsonString = JSON.stringify(cursorData); if (this.cursorCaptureFirstWrite) { fs.appendFileSync(filepath, jsonString); this.cursorCaptureFirstWrite = false; } else { fs.appendFileSync(filepath, "," + jsonString); } // Son pozisyonu sakla this.lastCapturedData = { ...cursorData }; } } catch (error) { console.error("Cursor capture error:", error); } }, interval); // Configurable FPS this.emit("cursorCaptureStarted", filepath); resolve(true); } catch (error) { reject(error); } }); } /** * Cursor capture durdurur - dosya yazma işlemini sonlandırır */ async stopCursorCapture() { return new Promise((resolve, reject) => { try { if (!this.cursorCaptureInterval) { return resolve(false); } // Interval'ı durdur clearInterval(this.cursorCaptureInterval); this.cursorCaptureInterval = null; // Dosyayı kapat if (this.cursorCaptureFile) { const fs = require("fs"); fs.appendFileSync(this.cursorCaptureFile, "]"); this.cursorCaptureFile = null; } // Değişkenleri temizle this.lastCapturedData = null; this.cursorCaptureStartTime = null; this.cursorCaptureFirstWrite = true; this.cursorDisplayInfo = null; this.emit("cursorCaptureStopped"); resolve(true); } catch (error) { reject(error); } }); } /** * Anlık cursor pozisyonunu ve tipini döndürür * Display-relative koordinatlar döner (her zaman pozitif) */ getCursorPosition() { try { const position = nativeBinding.getCursorPosition(); // Cursor hangi display'de ise o display'e relative döndür return this.getDisplayRelativePositionSync(position); } catch (error) { throw new Error("Failed to get cursor position: " + error.message); } } /** * Global koordinatları en uygun display'e relative çevirir (sync version) */ getDisplayRelativePositionSync(position) { try { // Cache'lenmiş displays'leri kullan if (!this.cachedDisplays) { // İlk çağrı - global koordinat döndür ve cache başlat this.refreshDisplayCache(); return position; } // Cursor hangi display içinde ise onu bul for (const display of this.cachedDisplays) { const x = parseInt(display.x); const y = parseInt(display.y); const width = parseInt(display.resolution.split("x")[0]); const height = parseInt(display.resolution.split("x")[1]); if ( position.x >= x && position.x < x + width && position.y >= y && position.y < y + height ) { // Bu display içinde return { x: position.x - x, y: position.y - y, cursorType: position.cursorType, eventType: position.eventType, displayId: display.id, displayIndex: this.cachedDisplays.indexOf(display), }; } } // Hiçbir display'de değilse main display'e relative döndür const mainDisplay = this.cachedDisplays.find((d) => d.isPrimary) || this.cachedDisplays[0]; if (mainDisplay) { return { x: position.x - parseInt(mainDisplay.x), y: position.y - parseInt(mainDisplay.y), cursorType: position.cursorType, eventType: position.eventType, displayId: mainDisplay.id, displayIndex: this.cachedDisplays.indexOf(mainDisplay), outsideDisplay: true, }; } // Fallback: global koordinat return position; } catch (error) { // Hata durumunda global koordinat döndür return position; } } /** * Display cache'ini refresh eder */ async refreshDisplayCache() { try { this.cachedDisplays = await this.getDisplays(); } catch (error) { console.warn("Display cache refresh failed:", error.message); } } /** * Native cursor modülünü warm-up yapar (cold start delay'ini önler) */ warmUpCursor() { // Async warm-up to prevent blocking constructor setTimeout(() => { try { // Silent warm-up call nativeBinding.getCursorPosition(); } catch (error) { // Ignore warm-up errors } }, 10); // 10ms delay to not block initialization } /** * getCurrentCursorPosition alias for getCursorPosition (backward compatibility) */ getCurrentCursorPosition() { return this.getCursorPosition(); } /** * Cursor capture durumunu döndürür */ getCursorCaptureStatus() { return { isCapturing: !!this.cursorCaptureInterval, outputFile: this.cursorCaptureFile || null, startTime: this.cursorCaptureStartTime || null, displayInfo: this.cursorDisplayInfo || null, }; } /** * Native modül bilgilerini döndürür */ getModuleInfo() { return { version: require("./package.json").version, platform: process.platform, arch: process.arch, nodeVersion: process.version, nativeModule: "mac_recorder.node", }; } async getDisplaysWithThumbnails(options = {}) { const displays = await this.getDisplays(); // Get thumbnails for each display const displayPromises = displays.map(async (display) => { try { const thumbnail = await this.getDisplayThumbnail(display.id, options); return { ...display, thumbnail, }; } catch (error) { return { ...display, thumbnail: null, thumbnailError: error.message, }; } }); return Promise.all(displayPromises); } async getWindowsWithThumbnails(options = {}) { const windows = await this.getWindows(); // Get thumbnails for each window const windowPromises = windows.map(async (window) => { try { const thumbnail = await this.getWindowThumbnail(window.id, options); return { ...window, thumbnail, }; } catch (error) { return { ...window, thumbnail: null, thumbnailError: error.message, }; } }); return Promise.all(windowPromises); } } // WindowSelector modülünü de export edelim MacRecorder.WindowSelector = require("./window-selector"); module.exports = MacRecorder;