feat: add secure electron ipc foundation

This commit is contained in:
Codex
2026-05-24 15:03:20 +02:00
parent b791cc39e8
commit 03218de754
10 changed files with 412 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
import {app, BrowserWindow, dialog, ipcMain} from 'electron';
import path from 'node:path';
import {IPC_CHANNELS} from '@shared/ipc';
import type {AssetKind, StartExportInput} from '@shared/ipc';
import {createSecureWindow, applySecurityPolicy} from './security';
import {createProject, importAssetFile, openProject, saveProject} from './project-service';
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
const selectFile = async (filters: Electron.FileFilter[]) => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters,
});
return {canceled: result.canceled, filePath: result.filePaths[0]};
};
const registerIpc = (): void => {
ipcMain.handle(IPC_CHANNELS.selectProjectDirectory, async () => {
const result = await dialog.showOpenDialog({properties: ['openDirectory', 'createDirectory']});
return {canceled: result.canceled, filePath: result.filePaths[0]};
});
ipcMain.handle(IPC_CHANNELS.selectBackgroundImage, () =>
selectFile([{name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp']}]),
);
ipcMain.handle(IPC_CHANNELS.selectAudioFile, () =>
selectFile([{name: 'Audio', extensions: ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg']}]),
);
ipcMain.handle(IPC_CHANNELS.selectProjectFile, () =>
selectFile([{name: 'MV Visualizer Project', extensions: ['json']}]),
);
ipcMain.handle(IPC_CHANNELS.createProject, (_event, input) => createProject(input));
ipcMain.handle(IPC_CHANNELS.openProject, (_event, filePath: string) => openProject(filePath));
ipcMain.handle(IPC_CHANNELS.saveProject, (_event, state) => saveProject(state));
ipcMain.handle(IPC_CHANNELS.importAsset, (_event, projectDir: string, sourcePath: string, kind: AssetKind) =>
importAssetFile(projectDir, sourcePath, kind),
);
ipcMain.handle(IPC_CHANNELS.startExport, async (_event, _input: StartExportInput) => ({
active: false,
progress: 0,
message: 'Export service is not wired yet.',
}));
ipcMain.handle(IPC_CHANNELS.cancelExport, async () => ({active: false, progress: 0, message: 'No export active.'}));
ipcMain.handle(IPC_CHANNELS.getExportStatus, async () => ({active: false, progress: 0, message: 'Idle'}));
};
const createWindow = async (): Promise<void> => {
const preloadPath = path.join(app.getAppPath(), 'dist-electron/preload/index.cjs');
const mainWindow = createSecureWindow(preloadPath);
if (isDev && process.env.VITE_DEV_SERVER_URL) {
await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
mainWindow.webContents.openDevTools({mode: 'detach'});
} else {
await mainWindow.loadFile(path.join(app.getAppPath(), 'dist/index.html'));
}
};
applySecurityPolicy();
app.whenReady().then(() => {
registerIpc();
void createWindow();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
void createWindow();
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
+68
View File
@@ -0,0 +1,68 @@
import {randomUUID} from 'node:crypto';
import {mkdir, copyFile, readFile, writeFile} from 'node:fs/promises';
import path from 'node:path';
import {ASSET_DIR, PROJECT_FILE_NAME, createDefaultConfig, projectConfigSchema} from '@shared/project';
import type {AssetKind} from '@shared/ipc';
import type {NewProjectInput, ProjectConfig, ProjectState} from '@shared/project';
import {normalizeRelativePath, sanitizeFileName} from '@shared/files';
const assetName = (sourcePath: string, kind: AssetKind): string => {
const extension = path.extname(sourcePath);
const stem = kind === 'audio' ? 'audio' : 'background';
return `${stem}${extension.toLowerCase()}`;
};
const ensureProjectDirs = async (projectDir: string): Promise<void> => {
await mkdir(path.join(projectDir, ASSET_DIR), {recursive: true});
await mkdir(path.join(projectDir, 'exports'), {recursive: true});
};
export const projectFilePath = (projectDir: string): string => path.join(projectDir, PROJECT_FILE_NAME);
export const importAssetFile = async (projectDir: string, sourcePath: string, kind: AssetKind): Promise<string> => {
await ensureProjectDirs(projectDir);
const fileName = assetName(sourcePath, kind);
const target = path.join(projectDir, ASSET_DIR, fileName);
await copyFile(sourcePath, target);
return normalizeRelativePath(path.relative(projectDir, target));
};
export const createProject = async (input: NewProjectInput): Promise<ProjectState> => {
await ensureProjectDirs(input.projectDir);
const backgroundImagePath = await importAssetFile(input.projectDir, input.backgroundImagePath, 'background');
const audioPath = await importAssetFile(input.projectDir, input.audioPath, 'audio');
const config = createDefaultConfig({
id: randomUUID(),
locale: input.locale,
formatId: input.formatId,
backgroundImagePath,
audioPath,
artistName: input.artistName,
songName: input.songName,
});
config.export.outputFileName = sanitizeFileName(config.export.outputFileName);
return saveProject({projectDir: input.projectDir, config});
};
export const openProject = async (filePath: string): Promise<ProjectState> => {
const raw = await readFile(filePath, 'utf8');
const config = projectConfigSchema.parse(JSON.parse(raw));
return {
projectDir: path.dirname(filePath),
config,
};
};
export const saveProject = async (state: ProjectState): Promise<ProjectState> => {
await ensureProjectDirs(state.projectDir);
const config: ProjectConfig = projectConfigSchema.parse({
...state.config,
export: {
...state.config.export,
outputFileName: sanitizeFileName(state.config.export.outputFileName),
},
});
await writeFile(projectFilePath(state.projectDir), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
return {projectDir: state.projectDir, config};
};
+37
View File
@@ -0,0 +1,37 @@
import {app, BrowserWindow, shell} from 'electron';
export const applySecurityPolicy = (): void => {
app.on('web-contents-created', (_event, contents) => {
contents.setWindowOpenHandler(({url}) => {
if (url.startsWith('https://')) {
void shell.openExternal(url);
}
return {action: 'deny'};
});
contents.on('will-navigate', (event, url) => {
const currentUrl = contents.getURL();
if (url !== currentUrl) {
event.preventDefault();
}
});
});
};
export const createSecureWindow = (preloadPath: string): BrowserWindow =>
new BrowserWindow({
width: 1280,
height: 860,
minWidth: 1040,
minHeight: 720,
title: 'MV Visualizer',
backgroundColor: '#0c0f14',
webPreferences: {
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
},
});
+22
View File
@@ -0,0 +1,22 @@
import {contextBridge, ipcRenderer} from 'electron';
import type {AssetKind, MvApi, StartExportInput} from '@shared/ipc';
import {IPC_CHANNELS} from '@shared/ipc';
import type {NewProjectInput, ProjectState} from '@shared/project';
const api: MvApi = {
selectProjectDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.selectProjectDirectory),
selectBackgroundImage: () => ipcRenderer.invoke(IPC_CHANNELS.selectBackgroundImage),
selectAudioFile: () => ipcRenderer.invoke(IPC_CHANNELS.selectAudioFile),
selectProjectFile: () => ipcRenderer.invoke(IPC_CHANNELS.selectProjectFile),
createProject: (input: NewProjectInput) => ipcRenderer.invoke(IPC_CHANNELS.createProject, input),
openProject: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.openProject, filePath),
saveProject: (state: ProjectState) => ipcRenderer.invoke(IPC_CHANNELS.saveProject, state),
importAsset: (projectDir: string, sourcePath: string, kind: AssetKind) =>
ipcRenderer.invoke(IPC_CHANNELS.importAsset, projectDir, sourcePath, kind),
startExport: (input: StartExportInput) => ipcRenderer.invoke(IPC_CHANNELS.startExport, input),
cancelExport: () => ipcRenderer.invoke(IPC_CHANNELS.cancelExport),
getExportStatus: () => ipcRenderer.invoke(IPC_CHANNELS.getExportStatus),
};
contextBridge.exposeInMainWorld('mvApi', api);
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
import type {MvApi} from '@shared/ipc';
declare global {
interface Window {
mvApi: MvApi;
}
}
+2
View File
@@ -0,0 +1,2 @@
export const isDev = (): boolean => process.env.NODE_ENV === 'development' || Boolean(process.env.VITE_DEV_SERVER_URL);
+20
View File
@@ -0,0 +1,20 @@
const RESERVED_WINDOWS_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
export const sanitizeFileName = (fileName: string): string => {
const trimmed = fileName.trim();
const withoutControlChars = trimmed.replace(/[\u0000-\u001f\u0080-\u009f]/g, '');
const safe = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/\.+$/g, '')
.trim();
if (!safe || RESERVED_WINDOWS_NAMES.test(safe)) {
return 'mv-visualizer-export.mp4';
}
return safe.endsWith('.mp4') ? safe : `${safe}.mp4`;
};
export const normalizeRelativePath = (value: string): string => value.replaceAll('\\', '/');
+22
View File
@@ -0,0 +1,22 @@
import {z} from 'zod';
export const videoFormatSchema = z.object({
id: z.enum(['16:9', '9:16', '1:1', '4:5']),
label: z.string(),
width: z.number().int().positive(),
height: z.number().int().positive(),
fps: z.number().int().positive().default(30),
});
export type VideoFormat = z.infer<typeof videoFormatSchema>;
export const VIDEO_FORMATS: VideoFormat[] = [
{id: '16:9', label: '16:9 Full HD', width: 1920, height: 1080, fps: 30},
{id: '9:16', label: '9:16 Vertical', width: 1080, height: 1920, fps: 30},
{id: '1:1', label: '1:1 Square', width: 1080, height: 1080, fps: 30},
{id: '4:5', label: '4:5 Portrait', width: 1080, height: 1350, fps: 30},
];
export const getVideoFormat = (id: VideoFormat['id']): VideoFormat =>
VIDEO_FORMATS.find((format) => format.id === id) ?? VIDEO_FORMATS[0];
+50
View File
@@ -0,0 +1,50 @@
import type {ProjectConfig, ProjectState, NewProjectInput} from './project';
export type AssetKind = 'background' | 'audio';
export type DialogFileResult = {
canceled: boolean;
filePath?: string;
};
export type ExportStatus = {
active: boolean;
progress: number;
message: string;
outputPath?: string;
error?: string;
};
export type StartExportInput = {
projectDir: string;
config: ProjectConfig;
};
export type MvApi = {
selectProjectDirectory: () => Promise<DialogFileResult>;
selectBackgroundImage: () => Promise<DialogFileResult>;
selectAudioFile: () => Promise<DialogFileResult>;
selectProjectFile: () => Promise<DialogFileResult>;
createProject: (input: NewProjectInput) => Promise<ProjectState>;
openProject: (filePath: string) => Promise<ProjectState>;
saveProject: (state: ProjectState) => Promise<ProjectState>;
importAsset: (projectDir: string, sourcePath: string, kind: AssetKind) => Promise<string>;
startExport: (input: StartExportInput) => Promise<ExportStatus>;
cancelExport: () => Promise<ExportStatus>;
getExportStatus: () => Promise<ExportStatus>;
};
export const IPC_CHANNELS = {
selectProjectDirectory: 'dialog:select-project-directory',
selectBackgroundImage: 'dialog:select-background-image',
selectAudioFile: 'dialog:select-audio-file',
selectProjectFile: 'dialog:select-project-file',
createProject: 'project:create',
openProject: 'project:open',
saveProject: 'project:save',
importAsset: 'project:import-asset',
startExport: 'export:start',
cancelExport: 'export:cancel',
getExportStatus: 'export:status',
} as const;
+105
View File
@@ -0,0 +1,105 @@
import {z} from 'zod';
import {getVideoFormat, videoFormatSchema} from './formats';
export const PROJECT_FILE_NAME = '.mvproj.json';
export const ASSET_DIR = 'assets';
export const EXPORT_DIR = 'exports';
export const PROJECT_VERSION = 1;
export const visualizerTypeSchema = z.enum(['bars', 'wave', 'ring', 'clean', 'neon', 'glitch']);
export type VisualizerType = z.infer<typeof visualizerTypeSchema>;
export const localeSchema = z.enum(['de', 'en']);
export type AppLocale = z.infer<typeof localeSchema>;
export const projectConfigSchema = z.object({
id: z.string().min(1),
version: z.literal(PROJECT_VERSION),
locale: localeSchema.default('de'),
format: videoFormatSchema,
assets: z.object({
backgroundImagePath: z.string().min(1),
audioPath: z.string().min(1),
}),
metadata: z.object({
artistName: z.string().min(1).max(120),
songName: z.string().min(1).max(160),
}),
visualizer: z.object({
type: visualizerTypeSchema.default('bars'),
intensity: z.number().min(0.1).max(2).default(1),
smoothing: z.number().min(0).max(0.95).default(0.78),
}),
style: z.object({
primaryColor: z.string().default('#f8fafc'),
accentColor: z.string().default('#2dd4bf'),
backgroundBlur: z.number().min(0).max(30).default(8),
overlayOpacity: z.number().min(0).max(0.85).default(0.36),
fontFamily: z.string().default('Inter, system-ui, sans-serif'),
textPosition: z.enum(['top-left', 'top-center', 'center', 'bottom-left', 'bottom-center']).default('bottom-left'),
visualizerPosition: z.enum(['bottom', 'center', 'top']).default('bottom'),
visualizerSize: z.number().min(0.2).max(1).default(0.48),
}),
export: z.object({
codec: z.literal('h264').default('h264'),
outputFileName: z.string().min(1),
}),
});
export type ProjectConfig = z.infer<typeof projectConfigSchema>;
export type ProjectState = {
projectDir: string;
config: ProjectConfig;
};
export type NewProjectInput = {
projectDir: string;
locale: AppLocale;
formatId: ProjectConfig['format']['id'];
backgroundImagePath: string;
audioPath: string;
artistName: string;
songName: string;
};
export const createDefaultConfig = (input: {
id: string;
locale?: AppLocale;
formatId?: ProjectConfig['format']['id'];
backgroundImagePath: string;
audioPath: string;
artistName: string;
songName: string;
}): ProjectConfig => {
const artistName = input.artistName.trim();
const songName = input.songName.trim();
return projectConfigSchema.parse({
id: input.id,
version: PROJECT_VERSION,
locale: input.locale ?? 'de',
format: getVideoFormat(input.formatId ?? '16:9'),
assets: {
backgroundImagePath: input.backgroundImagePath,
audioPath: input.audioPath,
},
metadata: {artistName, songName},
visualizer: {type: 'bars', intensity: 1, smoothing: 0.78},
style: {
primaryColor: '#f8fafc',
accentColor: '#2dd4bf',
backgroundBlur: 8,
overlayOpacity: 0.36,
fontFamily: 'Inter, system-ui, sans-serif',
textPosition: 'bottom-left',
visualizerPosition: 'bottom',
visualizerSize: 0.48,
},
export: {
codec: 'h264',
outputFileName: `${artistName} - ${songName}.mp4`,
},
});
};