/** * IPC surface for the main process. Every `ipcMain.handle` (and the one * fire-and-forget `taskbarProgress` receiver) lives here, extracted from index.ts * so that module is just app lifecycle + window creation and this is the single * place the renderer's IPC contract is wired (L2). * * The handful of handlers that must touch the window (folder picker parenting, * theme-synced background, taskbar progress) get the current window through the * `getMainWindow` accessor rather than a captured reference, so they always act on * the live window. The small theme helpers they share are defined and exported * here too, since index's system-theme bridge uses them as well. */ import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron' import { existsSync } from 'fs' import { IpcChannels, type StartDownloadOptions, type Settings, type HistoryEntry, type CommandTemplate, type MediaProfile, type YtdlpUpdateChannel, type SystemThemeInfo, type TaskbarProgress, type PersistedQueueItem } from '@shared/ipc' import { PAGE_BACKGROUND } from '@shared/theme' import { getYtdlpVersion, updateYtdlp } from './ytdlp' import { getFfmpegVersions } from './ffmpeg' import { getFfmpegSetupStatus, downloadFfmpeg, cancelFfmpegDownload, locateFfmpegManually } from './ffmpegSetup' import { getAria2cPath } from './binaries' import { checkForAppUpdate, downloadAppUpdate, runAppUpdate, cancelAppUpdate } from './updater' import { probeMedia } from './probe' import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download' import { whenYtdlpUpdateSettled } from './ytdlpLock' import { runTerminal, cancelTerminal } from './terminal' import { getSettings, setSettings } from './settings' import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history' import { listTemplates, saveTemplate, removeTemplate } from './templates' import { listProfiles, saveProfile, removeProfile } from './profiles' import { safeOpenPath, safeShowInFolder } from './reveal' import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies' import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog' import { listQueue, saveQueue } from './queue' import { exportBackup, importBackup } from './backup' import { listSources, getSource, removeSource, listMediaItems, setMediaItemDownloaded, setSourceWatched, setSourceProfile, pruneMediaItems } from './sources' import { indexSourceCancelable, cancelIndexing } from './indexer' import { syncWatchedSources } from './sync' import { getScheduledSync, setScheduledSync } from './schedule' import { getActiveBadge, getErrorBadge } from './badge' import { openPoTokenWindow } from './poToken' import { logger } from './logger' // --- Theme helpers (shared with index's system-theme bridge) ---------------- /** Resolve 'system' against the OS preference so callers always get a concrete color. */ export function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' { return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme } /** * Keep the OS-drawn title bar consistent with the in-app theme (W3). Setting * themeSource to 'light'/'dark' forces the caption/chrome to match; 'system' * restores OS control. Called at startup and on every theme change. */ export function applyNativeTheme(theme: Settings['theme']): void { nativeTheme.themeSource = theme } export function getSystemThemeInfo(): SystemThemeInfo { return { shouldUseDarkColors: nativeTheme.shouldUseDarkColors, shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors } } // --- IPC registration ------------------------------------------------------- export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null): void { // M30: a broken contextBridge causes the renderer to silently run in preview/mock // mode. Catch it here and show a hard error so the failure is never invisible. ipcMain.once(IpcChannels.preloadBridgeFailure, (_e, detail: unknown) => { dialog.showErrorBox( 'AeroFetch could not start', `The renderer bridge failed to initialize. Please reinstall the application.\n\n${String(detail)}` ) app.quit() }) // CC8: persist renderer-side failures (the M29 `logError` sites) to the main log // file — the renderer console is unreachable in a packaged build. Fire-and-forget. ipcMain.on(IpcChannels.logWrite, (_e, op: string, detail: string) => logger.error(`[renderer] ${op}`, detail) ) ipcMain.handle(IpcChannels.appVersion, () => app.getVersion()) ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate()) ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) => downloadAppUpdate(e.sender, url) ) ipcMain.handle(IpcChannels.appUpdateCancel, () => cancelAppUpdate()) ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath)) ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion()) ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions()) // First-run ffmpeg/ffprobe acquisition (they're no longer bundled). Status gates the // setup screen; download streams the pinned archive to e.sender; locate is the offline // fallback (folder picker parented to the window, like chooseFolder). ipcMain.handle(IpcChannels.ffmpegSetupStatus, () => getFfmpegSetupStatus()) ipcMain.handle(IpcChannels.ffmpegSetupDownload, (e) => downloadFfmpeg(e.sender)) ipcMain.handle(IpcChannels.ffmpegSetupCancel, () => cancelFfmpegDownload()) ipcMain.handle(IpcChannels.ffmpegSetupLocate, (e) => locateFfmpegManually(BrowserWindow.fromWebContents(e.sender) ?? undefined) ) // Whether the optional aria2c external downloader shipped with this build, so the // Settings toggle can disable itself instead of silently doing nothing (the flag // is a no-op without the binary — see download.ts's existsSync gate). ipcMain.handle(IpcChannels.aria2cAvailable, () => existsSync(getAria2cPath())) ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url)) ipcMain.handle(IpcChannels.downloadStart, async (e, opts: StartDownloadOptions) => { // L139: hold the spawn until any in-progress yt-dlp self-update has finished // swapping the exe, so a launch-time (auto-resumed) download waits it out instead // of racing the rewrite. Resolves immediately when no update is running. await whenYtdlpUpdateSettled() const result = startDownload(e.sender, opts) // Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach // download.ts's own close/error handlers, so log them here instead — unless the // download is incognito, which is never recorded (L136). if (!result.ok && !opts.incognito) { addErrorLog({ id: opts.id, url: opts.url, kind: opts.kind, error: result.error ?? 'Unknown error', occurredAt: Date.now() }) } return result }) ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id)) ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id)) ipcMain.handle(IpcChannels.terminalRun, async (e, id: string, args: string) => { await whenYtdlpUpdateSettled() // L139: hold behind an in-progress exe rewrite return runTerminal(e.sender, id, args) }) ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id)) ipcMain.handle(IpcChannels.chooseFolder, async (e, current?: string) => { const win = BrowserWindow.fromWebContents(e.sender) ?? undefined // Seed the picker at the currently-configured folder so it opens where the // user already points, not a generic default (W5). 'createDirectory' is a // macOS-only property and a no-op on Windows, so it's dropped (L58). const defaultPath = current && existsSync(current) ? current : undefined // Use the parented overload only when we actually have a window — passing a // forced non-null window that's gone can throw (L53). const res = win ? await dialog.showOpenDialog(win, { properties: ['openDirectory'], defaultPath }) : await dialog.showOpenDialog({ properties: ['openDirectory'], defaultPath }) return res.canceled || !res.filePaths[0] ? null : res.filePaths[0] }) ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p)) ipcMain.handle(IpcChannels.openUrl, (_e, url: string) => { try { const { protocol } = new URL(url) if (protocol === 'http:' || protocol === 'https:') void shell.openExternal(url) } catch { // Ignore malformed URLs. } }) ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p)) ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText()) ipcMain.handle(IpcChannels.settingsGet, () => getSettings()) ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial) => { const result = setSettings(partial) // Keep the window's native background and title bar in sync with the theme // so a compositor repaint never flashes a mismatched color, and the OS-drawn // caption always matches the in-app theme (W3). Use the validated result. if (partial.theme) { BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor( PAGE_BACKGROUND[resolveBackgroundMode(result.theme)] ) applyNativeTheme(result.theme) } return result }) ipcMain.handle(IpcChannels.systemThemeGet, () => getSystemThemeInfo()) ipcMain.handle(IpcChannels.openHighContrastSettings, () => shell.openExternal('ms-settings:easeofaccess-highcontrast') ) ipcMain.handle(IpcChannels.historyList, () => listHistory()) ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry)) ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id)) ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids)) ipcMain.handle(IpcChannels.historyClear, () => clearHistory()) ipcMain.handle(IpcChannels.cookiesLogin, (e, url: string) => // Parent the sign-in window to the app window (W6) so it groups under // AeroFetch instead of spawning a second taskbar button; theme its // background to the app's Light/Dark (UI23). openCookieLoginWindow( url, BrowserWindow.fromWebContents(e.sender) ?? undefined, PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)] ) ) ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus()) ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies()) ipcMain.handle(IpcChannels.templatesList, () => listTemplates()) ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) => saveTemplate(template) ) ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id)) ipcMain.handle(IpcChannels.profilesList, () => listProfiles()) ipcMain.handle(IpcChannels.profilesSave, (_e, profile: MediaProfile) => saveProfile(profile)) ipcMain.handle(IpcChannels.profilesRemove, (_e, id: string) => removeProfile(id)) ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) => previewCommand(opts) ) ipcMain.handle(IpcChannels.ytdlpUpdate, (_e, channel: YtdlpUpdateChannel) => updateYtdlp(channel)) ipcMain.handle(IpcChannels.errorLogList, () => listErrorLog()) ipcMain.handle(IpcChannels.errorLogClear, () => clearErrorLog()) // Persisted download queue (M4): the renderer mirrors its durable items here and // rehydrates them on launch, so saved/scheduled/pending downloads survive a quit. ipcMain.handle(IpcChannels.queueList, () => listQueue()) ipcMain.handle(IpcChannels.queueSave, (_e, items: PersistedQueueItem[]) => saveQueue(items)) ipcMain.handle(IpcChannels.backupExport, (e) => exportBackup(BrowserWindow.fromWebContents(e.sender) ?? undefined) ) ipcMain.handle(IpcChannels.backupImport, async (e) => { const win = BrowserWindow.fromWebContents(e.sender) ?? undefined const result = await importBackup(win) // A restored backup may have changed the theme; keep the native window // background and title bar in sync the same way settingsSet does (W3). if (result.ok) { const theme = getSettings().theme win?.setBackgroundColor(PAGE_BACKGROUND[resolveBackgroundMode(theme)]) applyNativeTheme(theme) } return result }) // --- Media-manager sources (Pinchflat-style index) --- ipcMain.handle(IpcChannels.sourcesList, () => listSources()) ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId)) ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id)) ipcMain.handle( IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string, quality?: string) => setMediaItemDownloaded(id, filePath, quality) ) // Indexing pushes live progress to the requesting renderer over `indexProgress` // and resolves with the final result. ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) => indexSourceCancelable((p) => { if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p) }, url) ) ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => { const src = getSource(id) if (!src) return { ok: false, error: 'Source not found.' } return indexSourceCancelable((p) => { if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p) }, src.url) }) // Abort the in-flight index/reindex walk (kills the current probe child). (B2) ipcMain.handle(IpcChannels.sourceIndexCancel, () => cancelIndexing()) ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) => setSourceWatched(id, watched) ) ipcMain.handle(IpcChannels.sourceSetProfile, (_e, id: string, profileId: string | null) => setSourceProfile(id, profileId) ) ipcMain.handle(IpcChannels.sourcePruneItems, (_e, sourceId: string, itemIds: string[]) => pruneMediaItems(sourceId, itemIds) ) ipcMain.handle(IpcChannels.sourcesSync, (e) => syncWatchedSources((p) => { if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p) }) ) ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync()) // `time` (24h HH:MM) is validated inside setScheduledSync before touching schtasks (L51). ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean, time?: string) => setScheduledSync(enabled, time) ) // Reflect overall queue progress on the Windows taskbar and window title (SR8). ipcMain.handle(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => { const win = getMainWindow() if (!win || win.isDestroyed()) return if (p.mode === 'none') { win.setProgressBar(-1) win.setOverlayIcon(null, '') win.setTitle('AeroFetch') } else { win.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode }) const badge = p.mode === 'error' ? getErrorBadge() : getActiveBadge() const n = p.badgeCount ?? 0 const label = p.mode === 'error' ? 'Download error' : `${n} download${n !== 1 ? 's' : ''} active` win.setOverlayIcon(badge, label) win.setTitle(p.mode === 'error' ? 'AeroFetch — Error' : `AeroFetch — ${label}`) } }) // Open a YouTube WebView and extract a PO token for bot-check bypass (Phase P). ipcMain.handle(IpcChannels.youtubePoTokenMint, async () => { const token = await openPoTokenWindow( PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)] ) if (token) await setSettings({ youtubePoToken: token }) return token }) }