diff --git a/electron-builder.json5 b/electron-builder.json5 index 18127a99..a8f1dc13 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -37,12 +37,13 @@ ], "icon": "icons/icons/mac/icon.icns", "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", - "extendInfo": { - "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", - "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", - "NSCameraUseContinuityCameraDeviceType": true, - "com.apple.security.device.audio-input": true - } + "extendInfo": { + "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", + "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", + "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", + "NSCameraUseContinuityCameraDeviceType": true, + "com.apple.security.device.audio-input": true + } }, "linux": { "target": [ diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f66af16a..07253480 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -29,12 +29,38 @@ interface Window { openSourceSelector: () => Promise; selectSource: (source: ProcessedDesktopSource) => Promise; getSelectedSource: () => Promise; + requestCameraAccess: () => Promise<{ + success: boolean; + granted: boolean; + status: string; + error?: string; + }>; getAssetBasePath: () => Promise; storeRecordedVideo: ( videoData: ArrayBuffer, fileName: string, - ) => Promise<{ success: boolean; path?: string; message?: string }>; - getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>; + ) => Promise<{ + success: boolean; + path?: string; + session?: import("../src/lib/recordingSession").RecordingSession; + message?: string; + error?: string; + }>; + storeRecordedSession: ( + payload: import("../src/lib/recordingSession").StoreRecordedSessionInput, + ) => Promise<{ + success: boolean; + path?: string; + session?: import("../src/lib/recordingSession").RecordingSession; + message?: string; + error?: string; + }>; + getRecordedVideoPath: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; setRecordingState: (recording: boolean) => Promise; getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; @@ -50,7 +76,17 @@ interface Window { ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + setCurrentRecordingSession: ( + session: import("../src/lib/recordingSession").RecordingSession | null, + ) => Promise<{ + success: boolean; + session?: import("../src/lib/recordingSession").RecordingSession; + }>; getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; + getCurrentRecordingSession: () => Promise<{ + success: boolean; + session?: import("../src/lib/recordingSession").RecordingSession; + }>; readBinaryFile: (filePath: string) => Promise<{ success: boolean; data?: ArrayBuffer; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 5665cd77..7738c48f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,11 +1,27 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron"; +import { + app, + BrowserWindow, + desktopCapturer, + dialog, + ipcMain, + screen, + shell, + systemPreferences, +} from "electron"; +import { + normalizeProjectMedia, + normalizeRecordingSession, + type RecordingSession, + type StoreRecordedSessionInput, +} from "../../src/lib/recordingSession"; import { RECORDINGS_DIR } from "../main"; const PROJECT_FILE_EXTENSION = "openscreen"; const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); +const RECORDING_SESSION_SUFFIX = ".session.json"; type SelectedSource = { name: string; @@ -14,6 +30,7 @@ type SelectedSource = { let selectedSource: SelectedSource | null = null; let currentProjectPath: string | null = null; +let currentRecordingSession: RecordingSession | null = null; function normalizePath(filePath: string) { return path.resolve(filePath); @@ -47,6 +64,101 @@ function isTrustedProjectPath(filePath?: string | null) { return normalizePath(filePath) === normalizePath(currentProjectPath); } +function setCurrentRecordingSessionState(session: RecordingSession | null) { + currentRecordingSession = session; +} + +function getSessionManifestPathForVideo(videoPath: string) { + const parsed = path.parse(videoPath); + const baseName = parsed.name.endsWith("-webcam") + ? parsed.name.slice(0, -"-webcam".length) + : parsed.name; + return path.join(parsed.dir, `${baseName}${RECORDING_SESSION_SUFFIX}`); +} + +async function loadRecordedSessionForVideoPath( + videoPath: string, +): Promise { + const normalizedVideoPath = normalizeVideoSourcePath(videoPath); + if (!normalizedVideoPath) { + return null; + } + + try { + const manifestPath = getSessionManifestPathForVideo(normalizedVideoPath); + const content = await fs.readFile(manifestPath, "utf-8"); + const session = normalizeRecordingSession(JSON.parse(content)); + if (!session) { + return null; + } + + const normalizedSession: RecordingSession = { + ...session, + screenVideoPath: normalizeVideoSourcePath(session.screenVideoPath) ?? session.screenVideoPath, + ...(session.webcamVideoPath + ? { + webcamVideoPath: + normalizeVideoSourcePath(session.webcamVideoPath) ?? session.webcamVideoPath, + } + : {}), + }; + + const targetPath = normalizePath(normalizedVideoPath); + const screenMatches = normalizePath(normalizedSession.screenVideoPath) === targetPath; + const webcamMatches = normalizedSession.webcamVideoPath + ? normalizePath(normalizedSession.webcamVideoPath) === targetPath + : false; + + return screenMatches || webcamMatches ? normalizedSession : null; + } catch { + return null; + } +} + +async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) { + const createdAt = + typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt) + ? payload.createdAt + : Date.now(); + const screenVideoPath = path.join(RECORDINGS_DIR, payload.screen.fileName); + await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); + + let webcamVideoPath: string | undefined; + if (payload.webcam) { + webcamVideoPath = path.join(RECORDINGS_DIR, payload.webcam.fileName); + await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + } + + const session: RecordingSession = webcamVideoPath + ? { screenVideoPath, webcamVideoPath, createdAt } + : { screenVideoPath, createdAt }; + setCurrentRecordingSessionState(session); + currentProjectPath = null; + + const telemetryPath = `${screenVideoPath}.cursor.json`; + if (pendingCursorSamples.length > 0) { + await fs.writeFile( + telemetryPath, + JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2), + "utf-8", + ); + } + pendingCursorSamples = []; + + const sessionManifestPath = path.join( + RECORDINGS_DIR, + `${path.parse(payload.screen.fileName).name}${RECORDING_SESSION_SUFFIX}`, + ); + await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + + return { + success: true, + path: screenVideoPath, + session, + message: "Recording session stored successfully", + }; +} + const CURSOR_TELEMETRY_VERSION = 1; const CURSOR_SAMPLE_INTERVAL_MS = 100; const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz @@ -129,6 +241,38 @@ export function registerIpcHandlers( return selectedSource; }); + ipcMain.handle("request-camera-access", async () => { + if (process.platform !== "darwin") { + return { success: true, granted: true, status: "granted" }; + } + + try { + const status = systemPreferences.getMediaAccessStatus("camera"); + if (status === "granted") { + return { success: true, granted: true, status }; + } + + if (status === "not-determined") { + const granted = await systemPreferences.askForMediaAccess("camera"); + return { + success: true, + granted, + status: granted ? "granted" : systemPreferences.getMediaAccessStatus("camera"), + }; + } + + return { success: true, granted: false, status }; + } catch (error) { + console.error("Failed to request camera access:", error); + return { + success: false, + granted: false, + status: "unknown", + error: String(error), + }; + } + }); + ipcMain.handle("open-source-selector", () => { const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin) { @@ -146,36 +290,30 @@ export function registerIpcHandlers( createEditorWindow(); }); - ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => { + ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => { try { - const videoPath = path.join(RECORDINGS_DIR, fileName); - await fs.writeFile(videoPath, Buffer.from(videoData)); - currentProjectPath = null; - - const telemetryPath = `${videoPath}.cursor.json`; - if (pendingCursorSamples.length > 0) { - await fs.writeFile( - telemetryPath, - JSON.stringify( - { version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, - null, - 2, - ), - "utf-8", - ); - } - pendingCursorSamples = []; - + return await storeRecordedSessionFiles(payload); + } catch (error) { + console.error("Failed to store recording session:", error); return { - success: true, - path: videoPath, - message: "Video stored successfully", + success: false, + message: "Failed to store recording session", + error: String(error), }; + } + }); + + ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => { + try { + return await storeRecordedSessionFiles({ + screen: { videoData, fileName }, + createdAt: Date.now(), + }); } catch (error) { - console.error("Failed to store video:", error); + console.error("Failed to store recorded video:", error); return { success: false, - message: "Failed to store video", + message: "Failed to store recorded video", error: String(error), }; } @@ -183,8 +321,14 @@ export function registerIpcHandlers( ipcMain.handle("get-recorded-video-path", async () => { try { + if (currentRecordingSession?.screenVideoPath) { + return { success: true, path: currentRecordingSession.screenVideoPath }; + } + const files = await fs.readdir(RECORDINGS_DIR); - const videoFiles = files.filter((file) => file.endsWith(".webm")); + const videoFiles = files.filter( + (file) => file.endsWith(".webm") && !file.endsWith("-webcam.webm"), + ); if (videoFiles.length === 0) { return { success: false, message: "No recorded video found" }; @@ -244,7 +388,9 @@ export function registerIpcHandlers( }); ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => { - const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath); + const targetVideoPath = normalizeVideoSourcePath( + videoPath ?? currentRecordingSession?.screenVideoPath, + ); if (!targetVideoPath) { return { success: true, samples: [] }; } @@ -416,7 +562,6 @@ export function registerIpcHandlers( } }); - let currentVideoPath: string | null = null; ipcMain.handle( "save-project-file", async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { @@ -502,8 +647,17 @@ export function registerIpcHandlers( const content = await fs.readFile(filePath, "utf-8"); const project = JSON.parse(content); currentProjectPath = filePath; - if (project && typeof project === "object" && typeof project.videoPath === "string") { - currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath; + if (project && typeof project === "object") { + const rawProject = project as { media?: unknown; videoPath?: unknown }; + const media = + normalizeProjectMedia(rawProject.media) ?? + (typeof rawProject.videoPath === "string" + ? { + screenVideoPath: + normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath, + } + : null); + setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null); } return { @@ -529,8 +683,17 @@ export function registerIpcHandlers( const content = await fs.readFile(currentProjectPath, "utf-8"); const project = JSON.parse(content); - if (project && typeof project === "object" && typeof project.videoPath === "string") { - currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath; + if (project && typeof project === "object") { + const rawProject = project as { media?: unknown; videoPath?: unknown }; + const media = + normalizeProjectMedia(rawProject.media) ?? + (typeof rawProject.videoPath === "string" + ? { + screenVideoPath: + normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath, + } + : null); + setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null); } return { success: true, @@ -546,18 +709,41 @@ export function registerIpcHandlers( }; } }); - ipcMain.handle("set-current-video-path", (_, path: string) => { - currentVideoPath = normalizeVideoSourcePath(path) ?? path; + ipcMain.handle("set-current-recording-session", (_, session: RecordingSession | null) => { + const normalized = normalizeRecordingSession(session); + setCurrentRecordingSessionState(normalized); + currentProjectPath = null; + return { success: true, session: normalized ?? undefined }; + }); + + ipcMain.handle("get-current-recording-session", () => { + return currentRecordingSession + ? { success: true, session: currentRecordingSession } + : { success: false }; + }); + + ipcMain.handle("set-current-video-path", async (_, path: string) => { + const restoredSession = await loadRecordedSessionForVideoPath(path); + if (restoredSession) { + setCurrentRecordingSessionState(restoredSession); + } else { + setCurrentRecordingSessionState({ + screenVideoPath: normalizeVideoSourcePath(path) ?? path, + createdAt: Date.now(), + }); + } currentProjectPath = null; return { success: true }; }); ipcMain.handle("get-current-video-path", () => { - return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false }; + return currentRecordingSession?.screenVideoPath + ? { success: true, path: currentRecordingSession.screenVideoPath } + : { success: false }; }); ipcMain.handle("clear-current-video-path", () => { - currentVideoPath = null; + setCurrentRecordingSessionState(null); return { success: true }; }); diff --git a/electron/main.ts b/electron/main.ts index 23bcc492..86d4e370 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -333,12 +333,12 @@ app.on("activate", () => { app.whenReady().then(async () => { // Allow microphone/media permission checks session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { - const allowed = ["media", "audioCapture", "microphone"]; + const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"]; return allowed.includes(permission); }); session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = ["media", "audioCapture", "microphone"]; + const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"]; callback(allowed.includes(permission)); }); diff --git a/electron/preload.ts b/electron/preload.ts index acdec4fc..ac9451d2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; +import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; contextBridge.exposeInMainWorld("electronAPI", { hudOverlayHide: () => { @@ -26,10 +27,16 @@ contextBridge.exposeInMainWorld("electronAPI", { getSelectedSource: () => { return ipcRenderer.invoke("get-selected-source"); }, + requestCameraAccess: () => { + return ipcRenderer.invoke("request-camera-access"); + }, storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { return ipcRenderer.invoke("store-recorded-video", videoData, fileName); }, + storeRecordedSession: (payload: StoreRecordedSessionInput) => { + return ipcRenderer.invoke("store-recorded-session", payload); + }, getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); @@ -57,9 +64,15 @@ contextBridge.exposeInMainWorld("electronAPI", { setCurrentVideoPath: (path: string) => { return ipcRenderer.invoke("set-current-video-path", path); }, + setCurrentRecordingSession: (session: RecordingSession | null) => { + return ipcRenderer.invoke("set-current-recording-session", session); + }, getCurrentVideoPath: () => { return ipcRenderer.invoke("get-current-video-path"); }, + getCurrentRecordingSession: () => { + return ipcRenderer.invoke("get-current-recording-session"); + }, readBinaryFile: (filePath: string) => { return ipcRenderer.invoke("read-binary-file", filePath); }, diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index b456dd64..e9a3fa28 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -4,7 +4,17 @@ import { BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; -import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md"; +import { + MdMic, + MdMicOff, + MdMonitor, + MdRestartAlt, + MdVideocam, + MdVideocamOff, + MdVideoFile, + MdVolumeOff, + MdVolumeUp, +} from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -23,7 +33,10 @@ const ICON_CONFIG = { volumeOff: { icon: MdVolumeOff, size: ICON_SIZE }, micOn: { icon: MdMic, size: ICON_SIZE }, micOff: { icon: MdMicOff, size: ICON_SIZE }, + webcamOn: { icon: MdVideocam, size: ICON_SIZE }, + webcamOff: { icon: MdVideocamOff, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, + restart: { icon: MdRestartAlt, size: ICON_SIZE }, record: { icon: BsRecordCircle, size: ICON_SIZE }, videoFile: { icon: MdVideoFile, size: ICON_SIZE }, folder: { icon: FaFolderOpen, size: ICON_SIZE }, @@ -51,12 +64,15 @@ export function LaunchWindow() { const { recording, toggleRecording, + restartRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled, + webcamEnabled, + setWebcamEnabled, } = useScreenRecorder(); const [recordingStart, setRecordingStart] = useState(null); const [elapsed, setElapsed] = useState(0); @@ -233,6 +249,17 @@ export function LaunchWindow() { ? getIcon("micOn", "text-green-400") : getIcon("micOff", "text-white/40")} + {/* Record/Stop group */} @@ -256,6 +283,18 @@ export function LaunchWindow() { )} + {/* Restart recording */} + {recording && ( + + + + )} + {/* Open video file */}