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;
  }
});