main.jsjavascript
/**
* @file Electron main process. Creates the window, owns the renderer↔main IPC
* bridge, spawns and supervises the Python audio worker (newline-delimited
* JSON over stdin/stdout), serves restored media over the ezl-file://
* protocol, and manages the native menu, single-instance lock, navigation
* guards, and shutdown sequence.
* @module main
*/
const { app, BrowserWindow, ipcMain, dialog, shell, Menu, protocol } = require('electron');
const { spawn } = require('child_process');
const path = require('path');
const { Readable } = require('node:stream');
const fs = require('fs');
/**
* Dev runs read backend selection (EZL_ENV / entitlement public key) from
* desktop/.env. Must load before auth.js is required — it snapshots
* process.env at module load. Packaged builds skip this entirely: they're
* pinned to prod inside auth.js and honor no env redirection.
*/
if (!app.isPackaged) {
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
}
/**
* Test/CI hook: make safeStorage work when there's no OS keyring. Headless
* Linux (CI) has none, so safeStorage.encryptString throws ("Encryption is not
* available") and token persistence — hence login — fails. Two parts, both
* required on Linux: the password-store switch (must be set before ready; a
* --password-store CLI arg after the app path is ignored), AND the explicit
* plaintext opt-in — without it encryptString still refuses under the basic
* backend. Test profiles are throwaway, so plaintext-at-rest is fine there.
* Unset in production → no effect.
*/
if (process.env.EZL_PASSWORD_STORE) {
app.commandLine.appendSwitch('password-store', process.env.EZL_PASSWORD_STORE);
app.whenReady().then(() => {
const { safeStorage } = require('electron');
safeStorage.setUsePlainTextEncryption?.(true);
});
}
const isPackaged = app.isPackaged;
/**
* Root directory for the bundled external binaries. Packaged: electron-builder
* copies resources/ → Contents/Resources/bin (see build.extraResources).
* Dev: resources/ lives in desktop/, one level above src/.
*/
const binPath = isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(__dirname, '..', 'resources');
/** Platform id ('darwin' | 'win32') — selects the per-platform binary subfolder. */
const platform = process.platform;
/** Bundled ffmpeg executable path; Windows binaries carry .exe, macOS none. */
const ffmpegExt = platform === 'win32' ? '.exe' : '';
const ffmpegPath = path.join(binPath, 'ffmpeg', platform, `ffmpeg${ffmpegExt}`);
/** Frozen Python worker binary path (used by packaged builds only). */
const pythonExt = platform === 'win32' ? '.exe' : '';
const pythonScriptPath = path.join(binPath, 'python', `worker${pythonExt}`);
/**
* Minimal .env loader (no dependency): populate process.env from desktop/.env
* so EZL_API_URL / EZL_ENTITLEMENT_PUBLIC_KEY can point the app at a deployed
* backend. Real shell env always wins; a missing .env is fine — defaults apply.
*/
try {
for (const line of fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8').split('\n')) {
if (/^\s*#/.test(line)) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
if (key && process.env[key] === undefined) {
process.env[key] = line.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
}
}
} catch { /* no .env — defaults apply */ }
const settings = require('./settings');
const secrets = require('./secrets');
/**
* Custom scheme used to stream media off disk on project restore (see
* registerEzlFileProtocol). Must be declared as privileged BEFORE
* app.whenReady() so it streams, supports the fetch API (decodeAudioData
* reads peaks through it), and honours HTTP range requests — without which a
* multi-GB <video src> would be slurped into memory and seeking would break.
* corsEnabled: media elements load ezl-file:// with crossorigin="anonymous"
* (a CORS-mode request) so Web Audio may process them; without this flag
* Chromium refuses the request outright (net::ERR_FAILED) before the handler
* even runs.
*/
protocol.registerSchemesAsPrivileged([
{ scheme: 'ezl-file', privileges: { standard: true, secure: true, stream: true, supportFetchAPI: true, corsEnabled: true } },
]);
// ── PYTHON WORKER ────────────────────────────────────────────────────────────
/** Handle to the single persistent Python worker subprocess (null until spawned). */
let pyWorker = null;
/** True once the worker has sent its ready signal and accepts writes. */
let pyReady = false;
/** Worker unavailable for the rest of the session (spawn failed / restart budget spent). */
let pyDown = false;
/** App shutdown in progress — worker exit is expected, don't restart. */
let pyQuitting = false;
/** Respawn attempts made this session. */
let pyRestartCount = 0;
/** Respawn attempts allowed per session; beyond this the worker stays down. */
const PY_MAX_RESTARTS = 3;
/** User-facing message for requests refused while the worker is down. */
const PY_DOWN_MESSAGE = "The audio engine isn't available. Please restart the app.";
/** Partial stdout line carried between data chunks. */
let lineBuffer = '';
/** Monotonic counter used to build request ids. */
let requestCounter = 0;
/** In-flight worker requests: id → { resolve, reject, timer }. */
const pendingRequests = new Map();
/** Payloads deferred until the worker sends its ready signal. */
let pyWriteQueue = [];
/**
* Reject every in-flight request and drop their queued payloads, so callers
* get a real error immediately instead of running out their timeouts.
* @param {Error} err Rejection reason handed to every pending caller.
*/
function failPendingRequests(err) {
pendingRequests.forEach(({ reject, timer }) => { clearTimeout(timer); reject(err); });
pendingRequests.clear();
pyWriteQueue = [];
}
/**
* Mark the worker permanently unavailable and tell the user once. Analysis,
* preview transcode, and export all run through it, so the session is
* effectively review-only from here — every later request is rejected
* up-front in pyWrite.
*/
function markWorkerDown() {
if (pyDown) return;
pyDown = true;
failPendingRequests(new Error(PY_DOWN_MESSAGE));
dialog.showErrorBox(
'Audio engine unavailable',
"The app's audio engine stopped and couldn't be restarted. " +
'Analyzing tracks and saving laybacks won\'t work until you restart the app.'
);
}
/**
* Route a request to the worker: write immediately, queue until the ready
* signal, or — when the worker is down — reject the pending entry right away
* so the caller sees a clear failure instead of a timeout.
* @param {string} id Request id already registered in pendingRequests.
* @param {string} payload Newline-terminated JSON message for the worker.
*/
function pyWrite(id, payload) {
if (pyDown) {
const pending = pendingRequests.get(id);
if (pending) {
clearTimeout(pending.timer);
pendingRequests.delete(id);
pending.reject(new Error(PY_DOWN_MESSAGE));
}
return;
}
if (pyReady) pyWorker.stdin.write(payload);
else pyWriteQueue.push(payload);
}
/**
* Spawn (or respawn) the Python audio worker and wire up its lifecycle.
*
* Command resolution — packaged: run the frozen PyInstaller worker binary
* directly (no interpreter, no source .py shipped). Dev: prefer the worker's
* own venv (worker/.venv, where requirements.txt is installed) so its deps
* never need to pollute the system python3; fall back to PATH python3 when no
* venv exists. worker/ lives at the repo root, a sibling of desktop/ — not
* inside the app dir.
*
* Environment — the worker builds its export "Created By" tag from
* EZL_APP_NAME / EZL_APP_VERSION instead of reading package.json off disk
* (which doesn't exist inside the frozen onefile binary). EZL_FFMPEG hands
* the worker the bundled ffmpeg location only when packaged; in dev it stays
* unset so the worker uses ffmpeg from PATH (the bundled binaries may not
* match the dev machine's arch). config.py reads these vars.
*
* lineBuffer is reset before each spawn: a crash mid-line must not corrupt
* the next worker's first message.
*
* The 'error' event fires when the binary itself can't start (e.g. python3
* not on PATH, frozen worker missing); without a listener that's an uncaught
* exception that crashes the app. A write racing the worker's death (before
* its 'exit' lands) errors on the stdin stream; the stdin 'error' listener
* only logs, because unhandled it's an uncaught exception and the exit
* handler owns recovery.
*
* stdout is parsed as newline-delimited JSON. The worker's ready message
* flushes the deferred write queue. Each response resolves its pending
* request; a transcode or video-proxy response with no waiter (its request
* already timed out) references a temp file nobody will read or delete, so
* its wav_path / proxy_path is unlinked here.
*
* On unexpected exit, in-flight requests are failed and the worker respawns
* with linear backoff, keeping the single-persistent-worker design. A worker
* that keeps dying gets a bounded number of attempts (PY_MAX_RESTARTS), then
* the session stays down.
*/
function spawnPython() {
const devVenvPython = platform === 'win32'
? path.join(__dirname, '..', '..', 'worker', '.venv', 'Scripts', 'python.exe')
: path.join(__dirname, '..', '..', 'worker', '.venv', 'bin', 'python3');
const cmd = isPackaged ? pythonScriptPath
: (fs.existsSync(devVenvPython) ? devVenvPython : 'python3');
const args = isPackaged ? [] : [path.join(__dirname, '..', '..', 'worker', 'worker.py')];
const env = { ...process.env };
env.EZL_APP_NAME = app.getName();
env.EZL_APP_VERSION = app.getVersion();
if (isPackaged) env.EZL_FFMPEG = ffmpegPath;
lineBuffer = '';
pyWorker = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env });
pyWorker.on('error', (err) => {
console.error('[python] failed to start:', err.message);
pyReady = false;
markWorkerDown();
});
pyWorker.stdin.on('error', err => console.error('[python] stdin:', err.message));
pyWorker.stdout.on('data', (chunk) => {
lineBuffer += chunk.toString();
let nl;
while ((nl = lineBuffer.indexOf('\n')) !== -1) {
const line = lineBuffer.slice(0, nl).trim();
lineBuffer = lineBuffer.slice(nl + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch (e) { console.error('[python] bad JSON:', line); continue; }
if (msg.status === 'ready') {
pyReady = true;
console.log('[python] worker ready');
pyWriteQueue.forEach(p => pyWorker.stdin.write(p));
pyWriteQueue = [];
return;
}
const pending = pendingRequests.get(msg.id);
if (pending) {
clearTimeout(pending.timer);
pendingRequests.delete(msg.id);
pending.resolve(msg);
} else if (typeof msg.wav_path === 'string') {
fs.promises.unlink(msg.wav_path).catch(() => {});
} else if (typeof msg.proxy_path === 'string') {
fs.promises.unlink(msg.proxy_path).catch(() => {});
}
}
});
pyWorker.stderr.on('data', d => console.error('[python]', d.toString().trim()));
pyWorker.on('exit', code => {
console.log('[python] exited with code', code);
pyReady = false;
if (pyQuitting || pyDown) return;
failPendingRequests(new Error(`The audio engine stopped unexpectedly (code ${code}).`));
if (pyRestartCount < PY_MAX_RESTARTS) {
pyRestartCount += 1;
console.log(`[python] restarting worker (attempt ${pyRestartCount}/${PY_MAX_RESTARTS})`);
setTimeout(spawnPython, 1000 * pyRestartCount);
} else {
markWorkerDown();
}
});
}
// ── IPC HANDLER ──────────────────────────────────────────────────────────────
/**
* IPC 'analyze-loudness' — measure a file's loudness via the worker.
* Payload: filePath (string, absolute). Resolves with the worker's analysis
* message (loudness / peak / duration fields); rejects after 30s.
*/
ipcMain.handle('analyze-loudness', (_event, filePath) => {
const id = `req-${++requestCounter}`;
const payload = JSON.stringify({ id, cmd: 'analyze', path: filePath }) + '\n';
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Analysis timed out for: ${filePath}`));
}, 30000);
pendingRequests.set(id, { resolve, reject, timer });
pyWrite(id, payload);
});
});
/**
* IPC 'get-preview-audio' — transcode a file the renderer can't play natively
* (e.g. AIFF — Chromium can't decode it) into a WAV for preview. Payload:
* filePath (string). The Python worker writes a temp WAV and returns its
* path; the bytes are read here and handed to the renderer (Electron
* transfers the Buffer as binary — no base64 bloat), then the temp file is
* deleted best-effort. The renderer wraps the bytes in a Blob URL.
* Transcoding a long track takes a few seconds — the 60s timeout keeps a
* generous ceiling. Resolves with a Buffer of WAV bytes.
*/
ipcMain.handle('get-preview-audio', (_event, filePath) => {
const id = `req-${++requestCounter}`;
const payload = JSON.stringify({ id, cmd: 'transcode_preview', path: filePath }) + '\n';
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Preview transcode timed out for: ${filePath}`));
}, 60000);
pendingRequests.set(id, {
resolve: async (resp) => {
if (resp.status !== 'ok' || !resp.wav_path) {
reject(new Error(resp.error || 'Preview transcode failed'));
return;
}
try {
const data = await fs.promises.readFile(resp.wav_path);
fs.promises.unlink(resp.wav_path).catch(() => {});
resolve(data);
} catch (e) {
reject(e);
}
},
reject,
timer,
});
pyWrite(id, payload);
});
});
// ── VIDEO PREVIEW PROXY ──────────────────────────────────────────────────────
/**
* Absolute realpath of the one live video preview proxy on disk, or null.
* Chromium has no decoder for pro acquisition codecs (ProRes, DNxHD, …), so
* those videos preview as a black frame. The renderer detects it and asks for
* a preview-only H.264/AAC proxy, which the worker writes to a temp .mp4.
* Unlike the audio path the bytes are NOT slurped over IPC — a proxy can be
* hundreds of MB, so it's served off disk over ezl-file:// (Range streaming,
* same as project restore) and deleted when replaced/released/quit. Export
* never sees the proxy; it reads the original source path.
*/
let currentVideoProxyPath = null;
/**
* Delete the current preview proxy (a temp file — best-effort cleanup) and
* revoke its ezl-file:// streaming permission.
*/
function releaseVideoProxy() {
if (!currentVideoProxyPath) return;
const stale = currentVideoProxyPath;
currentVideoProxyPath = null;
allowedMediaPaths.delete(stale);
fs.promises.unlink(stale).catch(() => {});
}
/**
* IPC 'get-video-preview-proxy' — build a preview-only H.264/AAC proxy for a
* video Chromium can't decode. Payload: filePath (string). Resolves with the
* proxy's absolute realpath, added to the ezl-file:// allowlist so the
* renderer can stream it; the previous proxy is released first (one live
* proxy at a time). The path is realpath'd because the ezl-file:// handler
* compares realpaths (macOS temp dirs live behind the /var → /private/var
* symlink). Timeout is 300s: ~7s covers a 60s 1080p ProRes spot with the
* hardware encoder, but long-form picture (or a software-encoder fallback)
* needs plenty of headroom.
*/
ipcMain.handle('get-video-preview-proxy', (_event, filePath) => {
const id = `req-${++requestCounter}`;
const payload = JSON.stringify({ id, cmd: 'transcode_video_preview', path: filePath }) + '\n';
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Video preview transcode timed out for: ${filePath}`));
}, 300000);
pendingRequests.set(id, {
resolve: async (resp) => {
if (resp.status !== 'ok' || !resp.proxy_path) {
reject(new Error(resp.error || 'Video preview transcode failed'));
return;
}
try {
releaseVideoProxy();
const real = await fs.promises.realpath(resp.proxy_path);
allowedMediaPaths.add(real);
currentVideoProxyPath = real;
resolve(real);
} catch (e) {
reject(e);
}
},
reject,
timer,
});
pyWrite(id, payload);
});
});
/**
* IPC 'release-video-preview-proxy' — the renderer unloaded the video (or is
* about to load a new one); reclaim the disk space. No payload.
*/
ipcMain.handle('release-video-preview-proxy', () => { releaseVideoProxy(); });
/**
* IPC 'show-folder-picker' — open a native directory chooser. No payload.
* Resolves with the chosen folder path, or null if the user cancelled.
*/
ipcMain.handle('show-folder-picker', async (_event) => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
buttonLabel: 'Choose',
});
return result.canceled ? null : result.filePaths[0];
});
/** IPC 'get-downloads-path' — resolve the OS Downloads directory. No payload. */
ipcMain.handle('get-downloads-path', () => app.getPath('downloads'));
/**
* IPC 'get-app-info' — app + OS info for the Contact Support diagnostics
* form. No payload. Returns { version, platform, osVersion }.
* process.getSystemVersion() is Electron's cross-platform OS-version accessor
* (e.g. '14.5' on macOS), so no `os` module import is needed; platform
* ('darwin' | 'win32' | 'linux') is mapped to a friendly label in the
* renderer.
*/
ipcMain.handle('get-app-info', () => ({
version: app.getVersion(),
platform: process.platform,
osVersion: process.getSystemVersion(),
}));
// ── SETTINGS PERSISTENCE ──────────────────────────────────────────────────────
/**
* IPC 'get-settings' — read the full settings object. No payload.
* files.defaultSaveFolder's null is resolved to the live Downloads path here
* (rather than storing it) so the renderer always receives a usable folder
* while the stored intent ("use the default") stays portable across machines.
*/
ipcMain.handle('get-settings', () => {
const s = settings.load();
return {
...s,
files: {
...s.files,
defaultSaveFolder: s.files.defaultSaveFolder || app.getPath('downloads'),
},
};
});
/**
* IPC 'set-settings' — merge a partial patch into the stored settings and
* return the full, resolved result. Payload: patch (object) — the renderer
* sends only the slice it changed; settings.update() preserves the rest.
*/
ipcMain.handle('set-settings', (_event, patch) => {
const s = settings.update(patch || {});
return {
...s,
files: {
...s.files,
defaultSaveFolder: s.files.defaultSaveFolder || app.getPath('downloads'),
},
};
});
/**
* IPC 'secret-available' / 'secret-has' / 'secret-delete' — the renderer-safe
* subset of the keychain-backed secrets store, and ONLY that subset. Plaintext
* get/set are never exposed over IPC — tokens are obtained and written from
* the main process during the OAuth flow (D8/D9). The renderer may ask
* whether secure storage works ('secret-available', no payload), whether a
* given account is linked ('secret-has', payload: key string), and request a
* disconnect that clears the token ('secret-delete', payload: key string).
* See secrets.js for the security rationale.
*/
ipcMain.handle('secret-available', () => secrets.isAvailable());
ipcMain.handle('secret-has', (_event, key) => secrets.hasSecret(key));
ipcMain.handle('secret-delete', (_event, key) => secrets.deleteSecret(key));
/**
* IPC 'load-partial' — read an app-authored HTML partial (Settings/User/Help
* modal) and hand its markup to the renderer for injection. Payload: name
* (string, one of the allowlist keys). The renderer can't fetch() local files
* under file:// + contextIsolation, so the read happens here. The allowlist
* is what blocks path traversal — only these names resolve to a file.
* 'help-md' is the Help modal's COPY (markdown), rendered into the help.html
* shell at runtime by renderHelpMarkdown() in the renderer.
*/
ipcMain.handle('load-partial', (_event, name) => {
const allow = {
settings: 'settings.html', user: 'user.html', help: 'help.html', 'help-md': 'help.md',
};
const file = allow[name];
if (!file) throw new Error(`Unknown partial: ${name}`);
return fs.readFileSync(path.join(__dirname, 'partials', file), 'utf8');
});
/**
* IPC 'path-exists' — existence check so the renderer can warn before
* overwriting an existing layback. Payload: p (string path). Returns true
* only for a path that already exists on disk; any error → false.
*/
ipcMain.handle('path-exists', (_event, p) => {
try { return !!p && fs.existsSync(p); } catch { return false; }
});
/**
* IPC 'reveal-in-folder' — show a saved file in Finder/Explorer. Payload:
* folderPath (string).
*/
ipcMain.handle('reveal-in-folder', (_event, folderPath) => {
shell.showItemInFolder(folderPath);
});
/**
* IPC 'open-account-portal' — open the web account portal in the user's
* default browser. No payload. Account editing (profile, password, billing)
* lives on the portal — the app stays read-only and just hands off. The URL
* carries a one-time SSO token so the browser lands as the SAME user the app
* is signed in as, not whoever the browser was logged in as (issue #140).
* accountPortalUrl handles the mint + fallback in main.
*/
ipcMain.handle('open-account-portal', async () => {
const { accountPortalUrl } = require('./auth');
return shell.openExternal(await accountPortalUrl());
});
/**
* IPC 'export-laybacks' — run a batch layback export through the worker.
* Payload: the renderer's export object (video path, audio paths + levels,
* trim points, output folder, …), forwarded verbatim with id and cmd added.
* Export can be slow for long files — the timeout allows up to 10 minutes
* per batch.
*/
ipcMain.handle('export-laybacks', (_event, payload) => {
const id = `req-${++requestCounter}`;
const msg = { ...payload, id, cmd: 'export_laybacks' };
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error('Export timed out'));
}, 600000);
pendingRequests.set(id, { resolve, reject, timer });
pyWrite(id, JSON.stringify(msg) + '\n');
});
});
/**
* IPC 'update-metadata' — stamp tags onto a finished export via the worker
* (mutagen). Payload: the renderer's metadata object (output path + tag
* values), forwarded verbatim with id and cmd added. Updates should be quick;
* the 10s timeout allows a few seconds for large files.
*/
ipcMain.handle('update-metadata', (_event, payload) => {
const id = `req-${++requestCounter}`;
const msg = { ...payload, id, cmd: 'update_metadata' };
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error('Metadata update timed out'));
}, 10000);
pendingRequests.set(id, { resolve, reject, timer });
pyWrite(id, JSON.stringify(msg) + '\n');
});
});
// ── PROJECT FILES (Save / Open Project) ───────────────────────────────────────
/** File-dialog filter for .ezlproj session files. */
const PROJECT_FILTERS = [{ name: 'EZLaybacks Project', extensions: ['ezlproj'] }];
/**
* Project paths a main-process dialog has handed out this session — the only
* paths the project read/write handlers will touch. The renderer owns all
* session state, so those handlers are pure plumbing (pick a path, then
* read/write JSON the renderer builds); the renderer never gets to name a
* location the user didn't pick, and a crafted IPC call with an arbitrary
* path is refused.
*/
const approvedProjectPaths = new Set();
/**
* Resolve a renderer-supplied project path against the approved set.
* @param {string} p Path received over IPC.
* @returns {string|null} The normalized path, only for a .ezlproj location
* that came from one of our dialogs; null otherwise.
*/
function approvedProjectPath(p) {
if (typeof p !== 'string' || !p) return null;
const resolved = path.resolve(p);
if (path.extname(resolved).toLowerCase() !== '.ezlproj') return null;
return approvedProjectPaths.has(resolved) ? resolved : null;
}
/**
* IPC 'save-project-dialog' — native Save dialog for a .ezlproj path. No
* payload. Resolves with the chosen path (recorded as approved) or null on
* cancel.
*/
ipcMain.handle('save-project-dialog', async () => {
const result = await dialog.showSaveDialog({
title: 'Save Project',
defaultPath: 'Untitled.ezlproj',
filters: PROJECT_FILTERS,
});
if (result.canceled || !result.filePath) return null;
approvedProjectPaths.add(path.resolve(result.filePath));
return result.filePath;
});
/**
* IPC 'open-project-dialog' — native Open dialog for a .ezlproj file. No
* payload. Resolves with the chosen path (recorded as approved) or null on
* cancel.
*/
ipcMain.handle('open-project-dialog', async () => {
const result = await dialog.showOpenDialog({
title: 'Open Project',
properties: ['openFile'],
filters: PROJECT_FILTERS,
});
if (result.canceled || !result.filePaths[0]) return null;
approvedProjectPaths.add(path.resolve(result.filePaths[0]));
return result.filePaths[0];
});
/**
* IPC 'write-project-file' — write the renderer-serialized project object as
* pretty JSON. Payload: filePath (string, must be dialog-approved), projectObj
* (object). Returns {ok} or {error}. The write goes to a sibling temp file,
* then renames over the target — rename is atomic on one filesystem, so a
* crash mid-write can't leave a truncated project behind (same pattern as
* settings-store.js persist()).
*/
ipcMain.handle('write-project-file', async (_event, filePath, projectObj) => {
const target = approvedProjectPath(filePath);
if (!target) return { error: 'Save location must be chosen from the Save Project dialog.' };
const tmp = `${target}.${process.pid}.tmp`;
try {
await fs.promises.writeFile(tmp, JSON.stringify(projectObj, null, 2), 'utf8');
await fs.promises.rename(tmp, target);
return { ok: true };
} catch (err) {
fs.promises.unlink(tmp).catch(() => {});
return { error: err.message };
}
});
/**
* IPC 'read-project-file' — read + JSON.parse a project file. Payload:
* filePath (string, must be dialog-approved). Returns the parsed object, or
* {error} on read/parse failure. Shape/version validation happens in the
* renderer (src/project.js parse()). Media listed in a user-opened project is
* the only content ezl-file:// will serve — it's registered here before the
* renderer starts building ezl-file URLs from these paths.
*/
ipcMain.handle('read-project-file', async (_event, filePath) => {
const target = approvedProjectPath(filePath);
if (!target) return { error: 'Project must be chosen from the Open Project dialog.' };
try {
const text = await fs.promises.readFile(target, 'utf8');
const project = JSON.parse(text);
await allowProjectMedia(project);
return project;
} catch (err) {
return { error: err.message };
}
});
// ── ezl-file:// PROTOCOL ───────────────────────────────────────────────────────
/** Content-Type by file extension for ezl-file:// responses. */
const EZL_MIME = {
'.mp4':'video/mp4', '.mov':'video/quicktime', '.m4a':'audio/mp4', '.aac':'audio/aac',
'.mp3':'audio/mpeg', '.wav':'audio/wav', '.aif':'audio/aiff', '.aiff':'audio/aiff',
'.aifc':'audio/aiff', '.flac':'audio/flac', '.ogg':'audio/ogg',
};
/**
* Media the renderer may stream over ezl-file:// — only the paths named in a
* project file the user opened via the dialog, plus the live video preview
* proxy (fresh imports use blob: URLs and never touch this protocol).
* Entries are stored realpath'd so the request-time comparison can't be
* dodged with symlinks or ../ segments; anything else gets a 403 from the
* protocol handler.
*/
const allowedMediaPaths = new Set();
/**
* Register a project's media files (video + tracks) as allowed ezl-file://
* targets, realpath'd on entry. A file that's missing is skipped — restore
* skips it too, so there's nothing to allow.
* @param {object} project Parsed .ezlproj object ({ video?.path, tracks[]?.path }).
* @returns {Promise<void>}
*/
async function allowProjectMedia(project) {
if (!project || typeof project !== 'object') return;
const candidates = [];
if (typeof project.video?.path === 'string') candidates.push(project.video.path);
if (Array.isArray(project.tracks)) {
for (const t of project.tracks) if (typeof t?.path === 'string') candidates.push(t.path);
}
for (const p of candidates) {
try { allowedMediaPaths.add(await fs.promises.realpath(p)); }
catch { /* file missing — restore skips it, nothing to allow */ }
}
}
/**
* Register the ezl-file:// protocol handler that streams allowlisted media
* off disk.
*
* Why it exists: on project restore there is no browser File object — only a
* saved absolute path — and the renderer can't build a File (or a blob: URL)
* from a path. This protocol bridges the gap: a restored track/video stores
* `ezl-file://localhost/abs/path` wherever a fresh drag/drop import stores a
* `blob:` URL, and the rest of the pipeline (<audio src>,
* fetch→decodeAudioData, waveform draw) consumes it transparently.
*
* It serves like a real file server — Content-Length on the full body, and
* HTTP Range (206) for partial requests. That matters because a
* <video>/<audio> element only reports a finite, seekable `.duration` when
* the source advertises a byte length and honors Range; without it the
* element's duration comes back Infinity, which on restore left the trim
* system with duration 0 (handles stuck at 0, undraggable). Range also keeps
* multi-GB video off the heap — only the requested slice is streamed. Range
* requests are "bytes=START-END" (END optional); the element uses the 206 +
* Content-Range to learn the total size and to seek — e.g. an initial
* "bytes=0-" is how it discovers the duration. An unsatisfiable range gets a
* 416. Requests without Range (e.g. fetch()→decodeAudioData for the
* waveform) get the full body + length.
*
* The URL carries an explicit `localhost` host (see descriptorFromPath): a
* `standard` scheme with an empty host (triple-slash) doesn't canonicalize an
* absolute path cleanly, so the host makes `pathname` reliable. The host is
* ignored and the file resolves from the pathname only —
* ezl-file://localhost/Users/foo/My%20Clip.mov → /Users/foo/My Clip.mov.
* The path is realpath'd (canonicalized) before the allowlist check so a
* symlink or ../ can't alias a request onto a file outside the opened
* project's media; a path that fails realpath (file moved/deleted
* mid-session) gets a 404, one not on the allowlist a 403.
*
* Every response carries Access-Control-Allow-Origin: '*'. The renderer's
* media elements load with crossorigin="anonymous" so Web Audio can process
* them: ezl-file:// is a different origin from the file:// page, and without
* CORS approval createMediaElementSource TAINTS the element and outputs pure
* silence (OA / restored tracks play nothing). The scheme is app-internal
* and allowlisted per-file, so '*' grants nothing beyond our own renderer.
*/
function registerEzlFileProtocol() {
protocol.handle('ezl-file', async (request) => {
let filePath;
try { filePath = await fs.promises.realpath(decodeURIComponent(new URL(request.url).pathname)); }
catch { return new Response(null, { status: 404 }); }
if (!allowedMediaPaths.has(filePath)) return new Response(null, { status: 403 });
let stat;
try { stat = await fs.promises.stat(filePath); }
catch { return new Response(null, { status: 404 }); }
const size = stat.size;
const type = EZL_MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
const range = request.headers.get('Range');
if (range) {
const m = /bytes=(\d*)-(\d*)/.exec(range) || [];
let start = m[1] ? parseInt(m[1], 10) : 0;
let end = m[2] ? parseInt(m[2], 10) : size - 1;
if (isNaN(start) || start >= size) {
return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${size}` } });
}
end = Math.min(end, size - 1);
const stream = fs.createReadStream(filePath, { start, end });
return new Response(Readable.toWeb(stream), {
status: 206,
headers: {
'Content-Type': type,
'Content-Length': String(end - start + 1),
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Access-Control-Allow-Origin': '*',
},
});
}
return new Response(Readable.toWeb(fs.createReadStream(filePath)), {
status: 200,
headers: {
'Content-Type': type,
'Content-Length': String(size),
'Accept-Ranges': 'bytes',
'Access-Control-Allow-Origin': '*',
},
});
});
}
// ── APPLICATION MENU ───────────────────────────────────────────────────────────
/**
* Build and install the native application menu. Menus are owned by the main
* process: a menu click fires HERE, but session state lives in the renderer —
* so Save/Open/New don't act directly, they message the renderer over
* menu:new-project / menu:save-project / menu:open-project (the first
* main→renderer direction in the app; see preload onMenu + renderer
* listeners). macOS expects the first menu to be the app-name menu
* (About / Quit); Windows has none, and Quit belongs under File there. The
* editMenu / viewMenu roles give correct per-platform labels and shortcuts
* for free.
* @param {BrowserWindow} win Window whose webContents receives the menu messages.
*/
function buildMenu(win) {
const isMac = process.platform === 'darwin';
const template = [
...(isMac ? [{
label: app.name,
submenu: [
{ role: 'about' },
{ label: 'Check for Updates…', click: () => require('./updater').checkNow() },
{ type: 'separator' },
{ role: 'quit' },
],
}] : []),
{
label: 'File',
submenu: [
{ label: 'New Project', accelerator: 'CmdOrCtrl+N', click: () => win.webContents.send('menu:new-project') },
{ type: 'separator' },
{ label: 'Save Project…', accelerator: 'CmdOrCtrl+S', click: () => win.webContents.send('menu:save-project') },
{ label: 'Open Project…', accelerator: 'CmdOrCtrl+O', click: () => win.webContents.send('menu:open-project') },
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
{ role: 'editMenu' },
{ role: 'viewMenu' },
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
// ── WINDOW ───────────────────────────────────────────────────────────────────
/**
* Icon for dev runs and Windows/Linux window chrome. Packaged builds get
* their icon from electron-builder (build/icon.icns baked into the bundle /
* .exe), and build/ isn't shipped in the app's `files` — hence the
* !isPackaged guard where this path is used.
*/
const devIconPath = path.join(__dirname, '..', 'build', 'icon.png');
/**
* Create the main application window (context-isolated, no node integration,
* preload bridge only) and load the renderer. The icon affects window chrome
* on Windows/Linux and is ignored on macOS.
* @returns {BrowserWindow} The new window — returned so the menu can target
* this window's webContents.
*/
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
icon: devIconPath,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
},
});
win.loadFile(path.join(__dirname, 'renderer.html'));
return win;
}
/**
* Navigation guards (issue #310): the app renders exactly one local file and
* opens nothing else, so any window.open or navigation is either a future
* feature (route it through the system browser, like the account-portal
* handoff already does) or an injection attempt (drop it — never a new
* BrowserWindow). Registered on web-contents-created so every current and
* future window is covered.
*/
app.on('web-contents-created', (_event, contents) => {
contents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) shell.openExternal(url);
return { action: 'deny' };
});
contents.on('will-navigate', (event, url) => {
if (url !== contents.getURL()) event.preventDefault();
});
});
/**
* One instance only. Both copies share the same user-data dir (settings,
* tokens, entitlement cache), so a second instance can clobber the first's
* state — and a copy launched from the mounted DMG runs translocated while
* the installed one is open, which is how "EZLaybacks is in use" drag-install
* confusion happens. The second launch just focuses the existing window.
*/
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', () => {
const win = BrowserWindow.getAllWindows()[0];
if (win) {
if (win.isMinimized()) win.restore();
win.focus();
}
});
}
/**
* Startup sequence. The macOS dock icon is set explicitly for `npm start`
* (dev) — packaged apps read it from the bundle's icon.icns, but an
* unpackaged Electron shows the stock icon. registerEzlFileProtocol must run
* before the window loads any ezl-file:// media. auth.register wires the
* licensing API (auth + entitlement IPC); its lazy window getter (win is
* created after it) lets the background refresh push `auth:session` updates.
* updater.start arms auto-update checks (packaged builds only — no-op in dev
* runs); the window handle drives the dock-icon download progress bar.
*/
app.whenReady().then(() => {
if (process.platform === 'darwin' && !app.isPackaged) {
app.dock.setIcon(devIconPath);
}
registerEzlFileProtocol();
require('./auth').register(() => win);
spawnPython();
const win = createWindow();
buildMenu(win);
require('./updater').start(require('./auth').entitlementAuthHeader, () => win);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) buildMenu(createWindow());
});
});
app.on('window-all-closed', () => { app.quit(); });
/**
* Shutdown. The worker's exit is expected from here on — it must not be
* restarted. Kill, don't just wave goodbye: the worker binary lives INSIDE
* the app bundle, and Squirrel's ShipIt refuses to install an update while
* any bundle process is alive ("App Still Running Error" — every
* 0.1.6→2026.715.2 install aborted this way). A worker that outlives us,
* even briefly, silently cancels the update the user just asked for. The
* proxy unlink is sync: will-quit won't wait for a promise, and an async
* delete could lose the race with process exit — leaving a multi-hundred-MB
* proxy in the temp dir.
*/
app.on('will-quit', () => {
pyQuitting = true;
pyWorker?.stdin?.end();
pyWorker?.kill('SIGKILL');
if (currentVideoProxyPath) {
try { fs.unlinkSync(currentVideoProxyPath); } catch { /* already gone */ }
currentVideoProxyPath = null;
}
});