feat: add secure electron ipc foundation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type {MvApi} from '@shared/ipc';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mvApi: MvApi;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const isDev = (): boolean => process.env.NODE_ENV === 'development' || Boolean(process.env.VITE_DEV_SERVER_URL);
|
||||
|
||||
@@ -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('\\', '/');
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user