diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 7361b26fc..0b692dfe2 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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); @@ -11,6 +11,8 @@ import { desktopCapturer, dialog, ipcMain, + net, + protocol, screen, shell, systemPreferences, @@ -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. @@ -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()); + }); + localMediaProtocolRegistered = true; +} + async function approveReadableVideoPath( filePath?: string | null, trustedDirs?: string[], @@ -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, diff --git a/electron/main.ts b/electron/main.ts index 1da3603ce..f2d634a16 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -8,6 +8,7 @@ import { ipcMain, Menu, nativeImage, + protocol, session, systemPreferences, Tray, @@ -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(); } @@ -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 diff --git a/electron/windows.ts b/electron/windows.ts index f94009ab0..047a8befe 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -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)); @@ -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(); @@ -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. @@ -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(); @@ -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. @@ -223,6 +275,7 @@ export function createCountdownOverlayWindow(): BrowserWindow { backgroundThrottling: false, }, }); + configureNavigationGuards(win); win.setIgnoreMouseEvents(true); diff --git a/index.html b/index.html index ce1c274aa..7fee6273d 100644 --- a/index.html +++ b/index.html @@ -1,8 +1,12 @@ - - + + + diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index c35c0c750..595a68376 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -33,6 +33,7 @@ import { getNativeAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { AnnotationOverlay } from "./AnnotationOverlay"; +import { fromFileUrl } from "./projectPersistence"; import { type AnnotationRegion, type BlurData, @@ -76,6 +77,47 @@ import { type MotionBlurState, } from "./videoPlayback/zoomTransform"; +const REMOTE_MEDIA_URL_RE = /^(https?:|blob:|data:)/i; +const LOCAL_MEDIA_PROTOCOL = "openscreen-media"; + +function getReadableMediaPath(mediaPath: string): string | null { + if (!mediaPath || REMOTE_MEDIA_URL_RE.test(mediaPath)) { + return null; + } + + if (/^file:\/\//i.test(mediaPath)) { + return fromFileUrl(mediaPath); + } + + return mediaPath; +} + +function getPlayableMediaPath(mediaPath: string): string { + const readablePath = getReadableMediaPath(mediaPath); + if (!readablePath) { + return mediaPath; + } + + return `${LOCAL_MEDIA_PROTOCOL}://local/${encodeURIComponent(readablePath)}`; +} + +function usePlayableMediaUrl(mediaPath?: string): string | undefined { + const [playableUrl, setPlayableUrl] = useState(() => + mediaPath ? getPlayableMediaPath(mediaPath) : undefined, + ); + + useEffect(() => { + if (!mediaPath) { + setPlayableUrl(undefined); + return; + } + + setPlayableUrl(getPlayableMediaPath(mediaPath)); + }, [mediaPath]); + + return playableUrl; +} + interface VideoPlaybackProps { videoPath: string; webcamVideoPath?: string; @@ -250,6 +292,8 @@ const VideoPlayback = forwardRef( const videoReadyRafRef = useRef(null); const smoothedAutoFocusRef = useRef(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); @@ -1184,7 +1228,7 @@ const VideoPlayback = forwardRef( useEffect(() => { const webcamVideo = webcamVideoRef.current; - if (!webcamVideo || !webcamVideoPath) { + if (!webcamVideo || !playableWebcamVideoPath) { setWebcamDimensions(null); return; } @@ -1203,11 +1247,11 @@ const VideoPlayback = forwardRef( return () => { webcamVideo.removeEventListener("loadedmetadata", handleLoadedMetadata); }; - }, [webcamVideoPath]); + }, [playableWebcamVideoPath]); useEffect(() => { const webcamVideo = webcamVideoRef.current; - if (!webcamVideo || !webcamVideoPath) { + if (!webcamVideo || !playableWebcamVideoPath) { return; } @@ -1232,17 +1276,17 @@ const VideoPlayback = forwardRef( 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 () => { @@ -1303,7 +1347,7 @@ const VideoPlayback = forwardRef( : "none", }} /> - {webcamVideoPath && + {playableWebcamVideoPath && (() => { const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle"); const useClipPath = !!clipPath; @@ -1325,7 +1369,7 @@ const VideoPlayback = forwardRef( >