Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,47 @@ let tray: Tray | null = null;
let selectedSourceName = "";
const isMac = process.platform === "darwin";
const trayIconSize = isMac ? 16 : 24;
const ALLOWED_MEDIA_PERMISSIONS = new Set([
"media",
"audioCapture",
"microphone",
"videoCapture",
"camera",
]);

// Tray Icons
const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize);
const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize);

function isTrustedRendererUrl(url: string) {
if (!url) return false;

try {
const parsed = new URL(url);

if (VITE_DEV_SERVER_URL) {
return parsed.origin === new URL(VITE_DEV_SERVER_URL).origin;
}

if (parsed.protocol !== "file:") {
return false;
}

return path.normalize(fileURLToPath(parsed)) === path.join(RENDERER_DIST, "index.html");
} catch {
return false;
}
}

function isTrustedMediaPermissionRequest(
webContents: Electron.WebContents | null | undefined,
permission: string,
) {
return (
ALLOWED_MEDIA_PERMISSIONS.has(permission) && isTrustedRendererUrl(webContents?.getURL() ?? "")
);
}

function createWindow() {
mainWindow = createHudOverlayWindow();
}
Expand Down Expand Up @@ -377,15 +413,13 @@ app.on("activate", () => {

// Register all IPC handlers when app is ready
app.whenReady().then(async () => {
// Allow microphone/media permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
return allowed.includes(permission);
});
// Allow capture permissions only for first-party renderer windows.
session.defaultSession.setPermissionCheckHandler((webContents, permission) =>
isTrustedMediaPermissionRequest(webContents, permission),
);

session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
callback(allowed.includes(permission));
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
callback(isTrustedMediaPermissionRequest(webContents, permission));
});

// Request microphone permission from macOS
Expand Down
57 changes: 55 additions & 2 deletions electron/windows.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { BrowserWindow, ipcMain, screen } from "electron";
import { BrowserWindow, ipcMain, screen, shell } from "electron";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand All @@ -15,9 +15,59 @@ const ASSET_BASE_DIR = process.defaultApp
? path.join(__dirname, "..", "public")
: process.resourcesPath;
const ASSET_BASE_URL_ARG = `--asset-base-url=${pathToFileURL(`${ASSET_BASE_DIR}${path.sep}`).toString()}`;
const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);

let hudOverlayWindow: BrowserWindow | null = null;

function isRendererAppUrl(url: string) {
if (!url) return false;

try {
const parsed = new URL(url);

if (parsed.protocol === "about:") {
return parsed.href === "about:blank";
}

if (VITE_DEV_SERVER_URL) {
return parsed.origin === new URL(VITE_DEV_SERVER_URL).origin;
}

if (parsed.protocol !== "file:") {
return false;
}

return path.normalize(fileURLToPath(parsed)) === path.join(RENDERER_DIST, "index.html");
} catch {
return false;
}
}

function openExternalUrl(url: string) {
try {
const parsed = new URL(url);
if (ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol)) {
void shell.openExternal(parsed.toString());
}
} catch {
// Ignore malformed renderer-supplied URLs.
}
}

function configureNavigationGuards(win: BrowserWindow) {
win.webContents.setWindowOpenHandler(({ url }) => {
openExternalUrl(url);
return { action: "deny" };
});

win.webContents.on("will-navigate", (event, url) => {
if (isRendererAppUrl(url)) return;

event.preventDefault();
openExternalUrl(url);
});
}

ipcMain.on("hud-overlay-hide", () => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.minimize();
Expand Down Expand Up @@ -63,6 +113,7 @@ export function createHudOverlayWindow(): BrowserWindow {
backgroundThrottling: false,
},
});
configureNavigationGuards(win);

// Follow the user across macOS Spaces (virtual desktops).
// Without this the HUD stays pinned to the Space it was first opened on.
Expand Down Expand Up @@ -121,10 +172,10 @@ export function createEditorWindow(): BrowserWindow {
additionalArguments: [ASSET_BASE_URL_ARG],
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
backgroundThrottling: false,
},
});
configureNavigationGuards(win);

// Maximize the window by default
win.maximize();
Expand Down Expand Up @@ -170,6 +221,7 @@ export function createSourceSelectorWindow(): BrowserWindow {
contextIsolation: true,
},
});
configureNavigationGuards(win);

// Follow the user across macOS Spaces so the selector appears on the
// active desktop regardless of where the HUD was originally opened.
Expand Down Expand Up @@ -223,6 +275,7 @@ export function createCountdownOverlayWindow(): BrowserWindow {
backgroundThrottling: false,
},
});
configureNavigationGuards(win);

win.setIgnoreMouseEvents(true);

Expand Down
8 changes: 6 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; base-uri 'self'; object-src 'none'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: file: https: http:; media-src 'self' data: blob: file: https: http:; font-src 'self' data: https://fonts.gstatic.com; worker-src 'self' blob:; connect-src 'self' data: blob: file: https: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
Expand Down
103 changes: 94 additions & 9 deletions src/components/video-editor/VideoPlayback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,89 @@ import {
type MotionBlurState,
} from "./videoPlayback/zoomTransform";

const REMOTE_MEDIA_URL_RE = /^(https?:|blob:|data:)/i;

function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const pathname = decodeURIComponent(url.pathname);

if (url.host && url.host !== "localhost") {
return `//${url.host}${pathname}`;
}

return pathname;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve Windows drive letters when decoding file URLs

Handle Windows file:///C:/... paths here the same way fromFileUrl does, otherwise pathname stays /C:/... and gets sent to readBinaryFile as an invalid local path on Windows. In this commit the fallback setPlayableUrl(mediaPath) points back to the original file:// URL while webSecurity is now enabled, so affected recordings can fail to load at all in the editor on Windows machines.

Useful? React with 👍 / 👎.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

function getReadableMediaPath(mediaPath: string): string | null {
if (!mediaPath || REMOTE_MEDIA_URL_RE.test(mediaPath)) {
return null;
}

if (/^file:\/\//i.test(mediaPath)) {
try {
return fileUrlToPath(mediaPath);
} catch {
return null;
}
}

return mediaPath;
}

function getVideoMimeType(mediaPath: string): string {
const lowerPath = mediaPath.toLowerCase();
if (lowerPath.endsWith(".mp4")) return "video/mp4";
if (lowerPath.endsWith(".mov")) return "video/quicktime";
if (lowerPath.endsWith(".webm")) return "video/webm";
return "application/octet-stream";
}
Comment on lines +95 to +102
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid double-wrapping already-converted media URLs.

If mediaPath is already openscreen-media://..., this function re-encodes it and generates a broken nested URL (kinda cursed edge case, but real if upstream ever passes back the transformed value).

quick guard
 function getPlayableMediaPath(mediaPath: string): string {
+	if (new RegExp(`^${LOCAL_MEDIA_PROTOCOL}://`, "i").test(mediaPath)) {
+		return mediaPath;
+	}
+
 	const readablePath = getReadableMediaPath(mediaPath);
 	if (!readablePath) {
 		return mediaPath;
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/video-editor/VideoPlayback.tsx` around lines 95 - 102,
getPlayableMediaPath is double-wrapping URLs (e.g., when mediaPath is already a
transformed URL like "openscreen-media://..." or already prefixed with
LOCAL_MEDIA_PROTOCOL), producing a broken nested URL; fix by early-returning
mediaPath when it already starts with the local protocol or known transformed
schemes before calling getReadableMediaPath/encoding. Update the guard in
getPlayableMediaPath to check for prefixes (referencing getPlayableMediaPath,
getReadableMediaPath and LOCAL_MEDIA_PROTOCOL) and only perform
encodeURIComponent wrapping when the path is not already a transformed URL.


function usePlayableMediaUrl(mediaPath?: string): string | undefined {
const [playableUrl, setPlayableUrl] = useState(mediaPath);

useEffect(() => {
if (!mediaPath) {
setPlayableUrl(undefined);
return;
}

const readablePath = getReadableMediaPath(mediaPath);
if (!readablePath || !window.electronAPI?.readBinaryFile) {
setPlayableUrl(mediaPath);
return;
}
const localPath = readablePath;

let canceled = false;
let objectUrl: string | null = null;

async function loadLocalMedia() {
const result = await window.electronAPI.readBinaryFile(localPath);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reading full video files into renderer memory

This now loads each local video via readBinaryFile (full fs.readFile) and wraps it in a Blob before playback, which forces complete file materialization in memory rather than streaming from disk. For long recordings this can cause large memory spikes (potentially twice when both main + webcam tracks are present) and degrade or crash editor sessions.

Useful? React with 👍 / 👎.

if (canceled) return;

if (!result.success || !result.data) {
setPlayableUrl(mediaPath);
return;
}

const blob = new Blob([result.data], { type: getVideoMimeType(localPath) });
objectUrl = URL.createObjectURL(blob);
setPlayableUrl(objectUrl);
}

void loadLocalMedia();

return () => {
canceled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [mediaPath]);
Comment on lines +104 to +116
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t feed the blocked local path into <video> first

useState(mediaPath) plus the setPlayableUrl(mediaPath) fallback means local files still get rendered as raw paths before readBinaryFile finishes, and again on read failure. With webSecurity back on, that’s exactly the load Chromium rejects, so the hidden video can fire onError before the blob URL arrives. kinda cursed race.

safer loading flow
 function usePlayableMediaUrl(mediaPath?: string): string | undefined {
-	const [playableUrl, setPlayableUrl] = useState(mediaPath);
+	const [playableUrl, setPlayableUrl] = useState<string | undefined>(() => {
+		if (!mediaPath) return undefined;
+		return getReadableMediaPath(mediaPath) ? undefined : mediaPath;
+	});
 
 	useEffect(() => {
 		if (!mediaPath) {
 			setPlayableUrl(undefined);
 			return;
@@
 		const readablePath = getReadableMediaPath(mediaPath);
 		if (!readablePath || !window.electronAPI?.readBinaryFile) {
 			setPlayableUrl(mediaPath);
 			return;
 		}
+		setPlayableUrl(undefined);
 		const localPath = readablePath;
@@
-			if (!result.success || !result.data) {
-				setPlayableUrl(mediaPath);
+			if (!result.success || !result.data) {
+				setPlayableUrl(undefined);
 				return;
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/video-editor/VideoPlayback.tsx` around lines 116 - 157,
Initialize playableUrl to undefined instead of mediaPath in usePlayableMediaUrl
and avoid setting the raw mediaPath into state until you've validated it or
completed the read; specifically, change useState(mediaPath) to
useState<string|undefined>(undefined), and only call setPlayableUrl(mediaPath)
as a deliberate fallback after determining the path is not a blocked local file
(i.e., when getReadableMediaPath(mediaPath) is falsy or when window.electronAPI
is unavailable) or after readBinaryFile fails—this prevents the <video> from
trying to load the blocked local path before the blob URL is ready; keep the
existing loadLocalMedia flow, cleanup of objectUrl, and use the same symbols:
usePlayableMediaUrl, getReadableMediaPath, window.electronAPI.readBinaryFile,
objectUrl, and setPlayableUrl.


return playableUrl;
}

interface VideoPlaybackProps {
videoPath: string;
webcamVideoPath?: string;
Expand Down Expand Up @@ -250,6 +333,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const videoReadyRafRef = useRef<number | null>(null);
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
const prevTargetProgressRef = useRef(0);
const playableVideoPath = usePlayableMediaUrl(videoPath);
const playableWebcamVideoPath = usePlayableMediaUrl(webcamVideoPath);

const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
Expand Down Expand Up @@ -1184,7 +1269,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(

useEffect(() => {
const webcamVideo = webcamVideoRef.current;
if (!webcamVideo || !webcamVideoPath) {
if (!webcamVideo || !playableWebcamVideoPath) {
setWebcamDimensions(null);
return;
}
Expand All @@ -1203,11 +1288,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return () => {
webcamVideo.removeEventListener("loadedmetadata", handleLoadedMetadata);
};
}, [webcamVideoPath]);
}, [playableWebcamVideoPath]);

useEffect(() => {
const webcamVideo = webcamVideoRef.current;
if (!webcamVideo || !webcamVideoPath) {
if (!webcamVideo || !playableWebcamVideoPath) {
return;
}

Expand All @@ -1232,17 +1317,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideo.play().catch(() => {
// Ignore webcam autoplay restoration failures.
});
}, [currentTime, isPlaying, speedRegions, webcamVideoPath]);
}, [currentTime, isPlaying, speedRegions, playableWebcamVideoPath]);

useEffect(() => {
const webcamVideo = webcamVideoRef.current;
if (!webcamVideo || !webcamVideoPath) {
if (!webcamVideo || !playableWebcamVideoPath) {
return;
}

webcamVideo.pause();
webcamVideo.currentTime = 0;
}, [webcamVideoPath]);
}, [playableWebcamVideoPath]);

useEffect(() => {
return () => {
Expand Down Expand Up @@ -1303,7 +1388,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: "none",
}}
/>
{webcamVideoPath &&
{playableWebcamVideoPath &&
(() => {
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
const useClipPath = !!clipPath;
Expand All @@ -1325,7 +1410,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
src={playableWebcamVideoPath}
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
style={{
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
Expand Down Expand Up @@ -1479,7 +1564,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
)}
<video
ref={videoRef}
src={videoPath}
src={playableVideoPath}
className="hidden"
preload="metadata"
playsInline
Expand Down
18 changes: 9 additions & 9 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
});

const safeHideCountdownOverlay = useCallback(async (runId: number) => {
try {
await window.electronAPI.hideCountdownOverlay(runId);
} catch (error) {
console.warn("Failed to hide countdown overlay:", error);
}
}, []);

useEffect(() => {
let cleanup: (() => void) | undefined;

Expand Down Expand Up @@ -450,7 +458,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamRecorder.current = null;
teardownMedia();
};
}, [teardownMedia]);
}, [teardownMedia, safeHideCountdownOverlay]);

const safeShowCountdownOverlay = async (value: number, runId: number) => {
try {
Expand All @@ -477,14 +485,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
};

const safeHideCountdownOverlay = async (runId: number) => {
try {
await window.electronAPI.hideCountdownOverlay(runId);
} catch (error) {
console.warn("Failed to hide countdown overlay:", error);
}
};

const isCountdownRunActive = (runId?: number) =>
runId === undefined || countdownRunId.current === runId;

Expand Down
Loading
Loading