Modern desktop apps often need to access local files securely while providing a polished UX. In this tutorial, we’ll build a YouTube‑style local video player in Electron that:
- Scans a local folder for video files
- Validates that files are real videos
-
Streams files via a custom
app://
protocol - Generates thumbnails on the fly
- Uses secure IPC with
contextBridge
and a strict CSP
Why a custom protocol?
Accessing local files directly from the renderer can be risky. A custom protocol lets us:
- Serve files via
app://
instead offile://
. - Apply security checks (e.g., prevent directory traversal).
- Enable proper streaming via Electron’s
net.fetch
withstream: true
.
This pattern is aligned with Electron’s recommended security practices. See the official protocol guide for protocol.registerSchemesAsPrivileged
and protocol.handle
: https://www.electronjs.org/fr/docs/latest/api/protocol#protocolregisterschemesasprivilegedcustomschemes
Registering a privileged protocol
In main.js
, we declare a custom app://
protocol with streaming and CSP support (per Electron protocol docs: https://www.electronjs.org/fr/docs/latest/api/protocol#protocolregisterschemesasprivilegedcustomschemes):
const { app, BrowserWindow, protocol, net } = require('electron');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true, // enable video/audio streaming
bypassCSP: true
}
}
]);
When the app is ready, we handle app://video/...
requests and stream files from ./video/
using net.fetch
:
const VIDEO_FOLDER = path.join(__dirname, 'video');
app.whenReady().then(() => {
protocol.handle('app', (req) => {
const { host, pathname } = new URL(req.url);
if (host === 'video') {
const fileName = decodeURIComponent(pathname.substring(1));
const videoPath = path.join(VIDEO_FOLDER, fileName);
// Security: keep requests inside VIDEO_FOLDER
const relativePath = path.relative(VIDEO_FOLDER, videoPath);
const isSafe = relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
if (!isSafe) {
return new Response('Forbidden', { status: 403, headers: { 'content-type': 'text/plain' } });
}
return net.fetch(pathToFileURL(videoPath).toString());
}
return new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
});
});
Why this matters:
-
protocol.registerSchemesAsPrivileged
must run beforeapp.whenReady()
. -
net.fetch
supports range requests and streaming for smooth playback. - We explicitly block path traversal attempts.
Secure IPC with a preload bridge
We expose a minimal API surface from the main process to the renderer via contextBridge
in preload.js
(docs: https://www.electronjs.org/fr/docs/latest/api/context-bridge, https://www.electronjs.org/fr/docs/latest/api/ipc-main, https://www.electronjs.org/fr/docs/latest/api/ipc-renderer):
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
getVideoFiles: () => ipcRenderer.invoke('get-video-files')
});
In BrowserWindow
, we enable:
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
This ensures the renderer can call window.electronAPI.getVideoFiles()
without direct access to Node.js or the full IPC surface.
Validating and listing local videos
The main process enumerates the video/
folder, filters by extension, checks basic file stats, and performs a lightweight magic‑byte verification:
const { ipcMain } = require('electron');
const fs = require('node:fs').promises;
const { createReadStream } = require('node:fs');
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.m4v'];
ipcMain.handle('get-video-files', async () => {
const files = await fs.readdir(VIDEO_FOLDER);
const videoFiles = [];
for (const file of files) {
const filePath = path.join(VIDEO_FOLDER, file);
const ext = path.extname(file).toLowerCase();
if (!VIDEO_EXTENSIONS.includes(ext)) continue;
const stats = await fs.stat(filePath);
if (!stats.isFile() || stats.size <= 0) continue;
const isValid = await verifyVideoFile(filePath, ext);
if (isValid) {
videoFiles.push({
name: file,
size: stats.size,
path: file,
url: `app://video/${encodeURIComponent(file)}`
});
}
}
return videoFiles;
});
The validator reads magic bytes for common formats and falls back to extension checks:
async function verifyVideoFile(filePath) {
return new Promise((resolve) => {
const stream = createReadStream(filePath, { start: 0, end: 11 });
const chunks = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('end', () => {
const hex = Buffer.concat(chunks).toString('hex');
if (hex.includes('66747970')) return resolve(true); // MP4/M4V/MOV
if (hex.startsWith('1a45dfa3')) return resolve(true); // WebM/MKV
if (hex.startsWith('52494646') && hex.includes('415649')) return resolve(true); // AVI
return resolve(true); // fallback: trust extension
});
stream.on('error', () => resolve(false));
});
}
The renderer: playlist UI and thumbnails
The UI is a simple, modern layout in index.html
with an info panel and a scrollable playlist. The CSP aligns with Electron’s security recommendations: https://www.electronjs.org/fr/docs/latest/tutorial/security
<meta http-equiv="Content-Security-Policy" content="default-src 'self' app:; style-src 'self' 'unsafe-inline'; script-src 'self'">
<video id="videoPlayer" controls></video>
<div id="videoList" class="loading">Loading videos...</div>
<script src="renderer.js"></script>
renderer.js
fetches the list of videos via the preload API and builds the playlist. It also generates a thumbnail per item by seeking to a specific timestamp in a hidden <video>
element:
async function loadVideoFiles() {
const videoFiles = await window.electronAPI.getVideoFiles();
const videoList = document.getElementById('videoList');
videoList.innerHTML = '';
videoFiles.forEach((videoFile, index) => {
const item = document.createElement('div');
item.className = 'video-item';
const thumbnail = createThumbnail(videoFile); // see below
const details = document.createElement('div');
details.className = 'video-details';
const name = document.createElement('div');
name.className = 'video-item-name';
name.textContent = videoFile.name;
details.appendChild(name);
item.appendChild(thumbnail);
item.appendChild(details);
item.addEventListener('click', () => loadVideo(videoFile, item));
videoList.appendChild(item);
if (index === 0) loadVideo(videoFile, item);
});
}
Thumbnail generation uses the HTML5 video API:
function createThumbnail(videoFile) {
const container = document.createElement('div');
container.className = 'video-thumbnail';
const thumbVideo = document.createElement('video');
thumbVideo.src = videoFile.url;
thumbVideo.muted = true;
thumbVideo.preload = 'metadata';
const durationOverlay = document.createElement('div');
durationOverlay.className = 'video-duration';
durationOverlay.textContent = '--:--';
container.appendChild(durationOverlay);
thumbVideo.addEventListener('loadedmetadata', () => {
durationOverlay.textContent = formatDuration(thumbVideo.duration);
thumbVideo.currentTime = Math.min(1, thumbVideo.duration * 0.1);
});
thumbVideo.addEventListener('seeked', () => {
container.insertBefore(thumbVideo, durationOverlay);
});
return container;
}
When a user selects an item, we load and autoplay the video, and update the info panel:
function loadVideo(videoFile, itemEl) {
const video = document.getElementById('videoPlayer');
video.src = videoFile.url; // app://video/<file>
video.load();
video.play().catch(() => {});
}
Security checklist
-
Context isolation:
contextIsolation: true
andnodeIntegration: false
inBrowserWindow
. -
CSP:
default-src 'self' app:
and self‑hosted scripts/styles only (security guide: https://www.electronjs.org/fr/docs/latest/tutorial/security). -
Custom protocol: registered with
secure
,supportFetchAPI
, andstream
(see docs: https://www.electronjs.org/fr/docs/latest/api/protocol#protocolregisterschemesasprivilegedcustomschemes). -
Path traversal protection: validate requests remain inside
video/
. -
Minimal IPC surface: only expose
getVideoFiles
.
Running the app
- Place videos in the
video/
folder. - Start the app:
npm install
npm start
Conclusion
This project demonstrates a practical pattern for serving local files safely in Electron using a privileged custom protocol, a secure preload bridge, and a clean renderer UI. The same approach works for audio players, document viewers, or any workflow that needs efficient local file access without compromising security.
- Repo: https://github.com/BillelMessaadi/electronjs-local-video-player
- License: MIT
Top comments (0)