Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
42 changes: 41 additions & 1 deletion electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";

const nodeRequire = createRequire(import.meta.url);

Expand All @@ -11,6 +11,8 @@ import {
desktopCapturer,
dialog,
ipcMain,
net,
protocol,
screen,
shell,
systemPreferences,
Expand All @@ -33,6 +35,7 @@ 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"]);
const LOCAL_MEDIA_PROTOCOL = "openscreen-media";

/**
* Paths explicitly approved by the user via file picker dialogs or project loads.
Expand Down Expand Up @@ -79,6 +82,41 @@ function hasAllowedImportVideoExtension(filePath: string): boolean {
return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}

let localMediaProtocolRegistered = false;

function parseLocalMediaPath(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== `${LOCAL_MEDIA_PROTOCOL}:` || parsed.hostname !== "local") {
return null;
}
const encodedPath = parsed.pathname.replace(/^\/+/, "");
return encodedPath ? decodeURIComponent(encodedPath) : null;
} catch {
return null;
}
}

function registerLocalMediaProtocol() {
if (localMediaProtocolRegistered) return;

protocol.handle(LOCAL_MEDIA_PROTOCOL, async (request) => {
const requestedPath = parseLocalMediaPath(request.url);
const normalizedPath = normalizeVideoSourcePath(requestedPath);

if (
!normalizedPath ||
!isPathAllowed(normalizedPath) ||
!hasAllowedImportVideoExtension(normalizedPath)
) {
return new Response("Not found", { status: 404 });
}

return net.fetch(pathToFileURL(normalizedPath).toString());
});
Comment on lines +103 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

🧩 Analysis chain

🌐 Web query:

In Electron 41, when using protocol.handle(), does net.fetch(url) automatically preserve incoming Range headers from request, or must handlers pass request.headers explicitly?

💡 Result:

In Electron 41, when using protocol.handle, calling net.fetch(url) with just a URL string does NOT automatically preserve incoming Range headers (or other request headers) from the handler's request object. Handlers must explicitly pass the request headers, typically by calling net.fetch(request, { bypassCustomProtocolHandlers: true }) where request is the GlobalRequest object received by the handler, or by constructing a new fetch with { method: request.method, headers: request.headers, body: request.body }.

Citations:


forward request headers to preserve media seek/range semantics.

line 115 calling bare net.fetch(fileUrl) drops Range and other headers from the incoming request. electron doesn't auto-preserve them—you gotta pass headers explicitly. lowkey risky for seek/scrub on bigger files since the backend won't know you're asking for a byte range.

nit: cleaner proxy
 	protocol.handle(LOCAL_MEDIA_PROTOCOL, async (request) => {
 		const requestedPath = parseLocalMediaPath(request.url);
 		const normalizedPath = normalizeVideoSourcePath(requestedPath);
@@
-		return net.fetch(pathToFileURL(normalizedPath).toString());
+		return net.fetch(pathToFileURL(normalizedPath).toString(), {
+			method: request.method,
+			headers: request.headers,
+		});
 	});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
protocol.handle(LOCAL_MEDIA_PROTOCOL, async (request) => {
const requestedPath = parseLocalMediaPath(request.url);
const normalizedPath = normalizeVideoSourcePath(requestedPath);
if (
!normalizedPath ||
!isPathAllowed(normalizedPath) ||
!hasAllowedImportVideoExtension(normalizedPath)
) {
return new Response("Not found", { status: 404 });
}
return net.fetch(pathToFileURL(normalizedPath).toString());
});
protocol.handle(LOCAL_MEDIA_PROTOCOL, async (request) => {
const requestedPath = parseLocalMediaPath(request.url);
const normalizedPath = normalizeVideoSourcePath(requestedPath);
if (
!normalizedPath ||
!isPathAllowed(normalizedPath) ||
!hasAllowedImportVideoExtension(normalizedPath)
) {
return new Response("Not found", { status: 404 });
}
return net.fetch(pathToFileURL(normalizedPath).toString(), {
method: request.method,
headers: request.headers,
});
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron/ipc/handlers.ts` around lines 103 - 116, The handler registered with
protocol.handle for LOCAL_MEDIA_PROTOCOL (the function starting with
parseLocalMediaPath/normalizeVideoSourcePath and validation via isPathAllowed
and hasAllowedImportVideoExtension) must forward the incoming request headers to
net.fetch instead of calling net.fetch(fileUrl) with no headers; extract the
headers from the request (request.headers or request.headers.getEntries()),
convert them into a plain headers object (ensuring Range and related headers are
preserved), and pass that object as the second argument to
net.fetch(pathToFileURL(normalizedPath).toString(), { headers: /* forwarded
headers */ }) so byte-range/seek semantics work correctly. Ensure header keys
and values are preserved and handle empty/undefined headers gracefully.

localMediaProtocolRegistered = true;
}

async function approveReadableVideoPath(
filePath?: string | null,
trustedDirs?: string[],
Expand Down Expand Up @@ -486,6 +524,8 @@ export function registerIpcHandlers(
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
switchToHud?: () => void,
) {
registerLocalMediaProtocol();

const supportsWindowOpacity = process.platform !== "linux";
const countdownOverlayState = {
visible: false,
Expand Down
64 changes: 56 additions & 8 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ipcMain,
Menu,
nativeImage,
protocol,
session,
systemPreferences,
Tray,
Expand Down Expand Up @@ -82,11 +83,60 @@ 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",
]);
const LOCAL_MEDIA_PROTOCOL = "openscreen-media";

protocol.registerSchemesAsPrivileged([
{
scheme: LOCAL_MEDIA_PROTOCOL,
privileges: {
standard: true,
secure: true,
stream: true,
supportFetchAPI: true,
},
},
]);

// 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 +427,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: openscreen-media: https: http:; font-src 'self' data: https://fonts.gstatic.com; worker-src 'self' blob:; connect-src 'self' 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
Loading
Loading