diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 757d997..b2b04db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,26 +42,3 @@ jobs: cache: npm - run: npm ci - run: npx vite build - - e2e: - name: E2E Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci - - run: npx playwright install --with-deps chromium - # Install Electron system dependencies not covered by Playwright's chromium deps - - run: npx electron . --version || sudo apt-get install -y libgbm-dev - - run: npm run build-vite - # xvfb provides a virtual display; Electron needs one on Linux even with show:false - - run: xvfb-run --auto-servernum npm run test:e2e - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: playwright-report - path: playwright-report/ - retention-days: 7 diff --git a/README.md b/README.md index 53d5479..b42355e 100644 --- a/README.md +++ b/README.md @@ -25,21 +25,20 @@ Screen Studio is an awesome product and this is definitely not a 1:1 clone. Open OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)

- OpenScreen App Preview 3 - OpenScreen App Preview 4 + OpenScreen App Preview 3 + OpenScreen App Preview 4

## Core Features -- Record your whole screen or specific windows. -- Add Automatic zooms or manual zooms (customizable depth levels). -- Record microphone audio and system audio capture. -- Customize the duration and position of zooms however you please. +- Record specific windows or your whole screen. +- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position. +- Record microphone and system audio. - Crop video recordings to hide parts. - Choose between wallpapers, solid colors, gradients or a custom background. - Motion blur for smoother pan and zoom effects. - Add annotations (text, arrows, images). - Trim sections of the clip. -- Customize speed at different segments. +- Customize the speed of different segments. - Export in different aspect ratios and resolutions. ## Installation @@ -78,9 +77,9 @@ You may need to grant screen recording permissions depending on your desktop env System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks: -- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still work). +- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works). - **Windows**: Works out of the box. -- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still works). +- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work). ## Built with - Electron diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 78d8344..e43f53c 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -14,6 +14,7 @@ import { import { normalizeProjectMedia, normalizeRecordingSession, + type ProjectMedia, type RecordingSession, type StoreRecordedSessionInput, } from "../../src/lib/recordingSession"; @@ -23,6 +24,143 @@ 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"; +const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]); + +/** + * Paths explicitly approved by the user via file picker dialogs or project loads. + * These are added at runtime when the user selects files from outside the default directories. + */ +const approvedPaths = new Set(); + +function approveFilePath(filePath: string): void { + approvedPaths.add(path.resolve(filePath)); +} + +function getAllowedReadDirs(): string[] { + return [RECORDINGS_DIR]; +} + +function isPathWithinDir(filePath: string, dirPath: string): boolean { + const resolved = path.resolve(filePath); + const resolvedDir = path.resolve(dirPath); + return resolved === resolvedDir || resolved.startsWith(resolvedDir + path.sep); +} + +function isPathAllowed(filePath: string): boolean { + const resolved = path.resolve(filePath); + if (approvedPaths.has(resolved)) return true; + return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir)); +} + +function hasAllowedImportVideoExtension(filePath: string): boolean { + return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase()); +} + +async function approveReadableVideoPath( + filePath?: string | null, + trustedDirs?: string[], +): Promise { + const normalizedPath = normalizeVideoSourcePath(filePath); + if (!normalizedPath) { + return null; + } + + if (isPathAllowed(normalizedPath)) { + return normalizedPath; + } + + if (!hasAllowedImportVideoExtension(normalizedPath)) { + return null; + } + + // When called with trustedDirs (e.g. from project load), only auto-approve + // paths within those directories. This prevents malicious project files from + // approving reads to arbitrary filesystem locations. + if (trustedDirs) { + const resolved = path.resolve(normalizedPath); + const withinTrusted = trustedDirs.some((dir) => isPathWithinDir(resolved, dir)); + if (!withinTrusted) { + return null; + } + } + + try { + const stats = await fs.stat(normalizedPath); + if (!stats.isFile()) { + return null; + } + } catch { + return null; + } + + approveFilePath(normalizedPath); + return normalizedPath; +} + +function resolveRecordingOutputPath(fileName: string): string { + const trimmed = fileName.trim(); + if (!trimmed) { + throw new Error("Invalid recording file name"); + } + + const parsedPath = path.parse(trimmed); + const hasTraversalSegments = trimmed.split(/[\\/]+/).some((segment) => segment === ".."); + const isNestedPath = + parsedPath.dir !== "" || + path.isAbsolute(trimmed) || + trimmed.includes("/") || + trimmed.includes("\\"); + if (hasTraversalSegments || isNestedPath || parsedPath.base !== trimmed) { + throw new Error("Recording file name must not contain path segments"); + } + + return path.join(RECORDINGS_DIR, parsedPath.base); +} + +async function getApprovedProjectSession( + project: unknown, + projectFilePath?: string, +): Promise { + if (!project || typeof project !== "object") { + return null; + } + + const rawProject = project as { media?: unknown; videoPath?: unknown }; + const media: ProjectMedia | null = + normalizeProjectMedia(rawProject.media) ?? + (typeof rawProject.videoPath === "string" + ? { + screenVideoPath: normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath, + } + : null); + + if (!media) { + return null; + } + + // Only auto-approve media paths within the project's directory or RECORDINGS_DIR. + // This prevents crafted project files from approving reads to arbitrary locations. + const trustedDirs = [RECORDINGS_DIR]; + if (projectFilePath) { + trustedDirs.push(path.dirname(path.resolve(projectFilePath))); + } + + const screenVideoPath = await approveReadableVideoPath(media.screenVideoPath, trustedDirs); + if (!screenVideoPath) { + throw new Error("Project references an invalid or unsupported screen video path"); + } + + const webcamVideoPath = media.webcamVideoPath + ? await approveReadableVideoPath(media.webcamVideoPath, trustedDirs) + : undefined; + if (media.webcamVideoPath && !webcamVideoPath) { + throw new Error("Project references an invalid or unsupported webcam video path"); + } + + return webcamVideoPath + ? { screenVideoPath, webcamVideoPath, createdAt: Date.now() } + : { screenVideoPath, createdAt: Date.now() }; +} type SelectedSource = { name: string; @@ -121,12 +259,12 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) { typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt) ? payload.createdAt : Date.now(); - const screenVideoPath = path.join(RECORDINGS_DIR, payload.screen.fileName); + const screenVideoPath = resolveRecordingOutputPath(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); + webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); } @@ -352,6 +490,14 @@ export function registerIpcHandlers( return { success: false, message: "Invalid file path" }; } + if (!isPathAllowed(normalizedPath)) { + console.warn( + "[read-binary-file] Rejected path outside allowed directories:", + normalizedPath, + ); + return { success: false, message: "Access denied: path outside allowed directories" }; + } + const data = await fs.readFile(normalizedPath); return { success: true, @@ -396,6 +542,14 @@ export function registerIpcHandlers( return { success: true, samples: [] }; } + if (!isPathAllowed(targetVideoPath)) { + console.warn( + "[get-cursor-telemetry] Rejected path outside allowed directories:", + targetVideoPath, + ); + return { success: true, samples: [] }; + } + const telemetryPath = `${targetVideoPath}.cursor.json`; try { const content = await fs.readFile(telemetryPath, "utf-8"); @@ -529,10 +683,17 @@ export function registerIpcHandlers( return { success: false, canceled: true }; } + const approvedPath = await approveReadableVideoPath(result.filePaths[0]); + if (!approvedPath) { + return { + success: false, + message: "Selected file is not a supported video", + }; + } currentProjectPath = null; return { success: true, - path: result.filePaths[0], + path: approvedPath, }; } catch (error) { console.error("Failed to open file picker:", error); @@ -658,19 +819,9 @@ export function registerIpcHandlers( const filePath = result.filePaths[0]; const content = await fs.readFile(filePath, "utf-8"); const project = JSON.parse(content); + const session = await getApprovedProjectSession(project, filePath); currentProjectPath = filePath; - 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); - } + setCurrentRecordingSessionState(session); return { success: true, @@ -695,18 +846,8 @@ export function registerIpcHandlers( const content = await fs.readFile(currentProjectPath, "utf-8"); const project = JSON.parse(content); - 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); - } + const session = await getApprovedProjectSession(project, currentProjectPath); + setCurrentRecordingSessionState(session); return { success: true, path: currentProjectPath, @@ -735,12 +876,22 @@ export function registerIpcHandlers( }); ipcMain.handle("set-current-video-path", async (_, path: string) => { - const restoredSession = await loadRecordedSessionForVideoPath(path); + const normalizedPath = normalizeVideoSourcePath(path); + if (!normalizedPath || !isPathAllowed(normalizedPath)) { + return { success: false, message: "Video path has not been approved" }; + } + + const restoredSession = await loadRecordedSessionForVideoPath(normalizedPath); if (restoredSession) { + // Approve all media paths from the restored session so they can be read later + approveFilePath(restoredSession.screenVideoPath); + if (restoredSession.webcamVideoPath) { + approveFilePath(restoredSession.webcamVideoPath); + } setCurrentRecordingSessionState(restoredSession); } else { setCurrentRecordingSessionState({ - screenVideoPath: normalizeVideoSourcePath(path) ?? path, + screenVideoPath: normalizedPath, createdAt: Date.now(), }); } diff --git a/package-lock.json b/package-lock.json index 70e3395..fdbd6b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.2.0", + "version": "1.3.0", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f1b66b8..249dd77 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,10 +1,11 @@ import { ChevronDown, Languages } from "lucide-react"; import { useEffect, useState } from "react"; -import { BsRecordCircle } from "react-icons/bs"; +import { BsPauseCircle, BsPlayCircle, 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 { + MdCancel, MdMic, MdMicOff, MdMonitor, @@ -41,8 +42,11 @@ const ICON_CONFIG = { micOff: { icon: MdMicOff, size: ICON_SIZE }, webcamOn: { icon: MdVideocam, size: ICON_SIZE }, webcamOff: { icon: MdVideocamOff, size: ICON_SIZE }, + pause: { icon: BsPauseCircle, size: ICON_SIZE }, + resume: { icon: BsPlayCircle, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, restart: { icon: MdRestartAlt, size: ICON_SIZE }, + cancel: { icon: MdCancel, size: ICON_SIZE }, record: { icon: BsRecordCircle, size: ICON_SIZE }, videoFile: { icon: MdVideoFile, size: ICON_SIZE }, folder: { icon: FaFolderOpen, size: ICON_SIZE }, @@ -77,8 +81,12 @@ export function LaunchWindow() { const { recording, + paused, + elapsedSeconds, toggleRecording, + togglePaused, restartRecording, + cancelRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, @@ -90,8 +98,6 @@ export function LaunchWindow() { webcamDeviceId, setWebcamDeviceId, } = useScreenRecorder(); - const [recordingStart, setRecordingStart] = useState(null); - const [elapsed, setElapsed] = useState(0); const showMicControls = microphoneEnabled && !recording; const showWebcamControls = webcamEnabled && !recording; @@ -146,25 +152,6 @@ export function LaunchWindow() { } }, [selectedCameraId, setWebcamDeviceId]); - useEffect(() => { - let timer: NodeJS.Timeout | null = null; - if (recording) { - if (!recordingStart) setRecordingStart(Date.now()); - timer = setInterval(() => { - if (recordingStart) { - setElapsed(Math.floor((Date.now() - recordingStart) / 1000)); - } - }, 1000); - } else { - setRecordingStart(null); - setElapsed(0); - if (timer) clearInterval(timer); - } - return () => { - if (timer) clearInterval(timer); - }; - }, [recording, recordingStart]); - useEffect(() => { if (!import.meta.env.DEV) { return; @@ -447,7 +434,11 @@ export function LaunchWindow() { {/* Record/Stop group */} + {recording && ( + + + + )} + {/* Restart recording */} {recording && ( @@ -477,6 +481,18 @@ export function LaunchWindow() { )} + {/* Cancel recording */} + {recording && ( + + + + )} + {/* Open video file */}