import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Notification, Menu } from 'electron' import { join, resolve } from 'path' import { existsSync } from 'fs' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { IpcChannels, type StartDownloadOptions, type Settings, type HistoryEntry, type CommandTemplate, type YtdlpUpdateChannel, type SystemThemeInfo, type TaskbarProgress } from '@shared/ipc' import { PAGE_BACKGROUND } from '@shared/theme' import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp' import { getFfmpegVersions } from './ffmpeg' import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater' import { probeMedia } from './probe' import { getAppIconImage } from './binaries' import { startDownload, cancelDownload, pauseDownload, previewCommand, hasActiveDownloads } from './download' import { runTerminal, cancelTerminal } from './terminal' import { getSettings, setSettings, ensureMediaDirs, applyLaunchAtStartup, migrateSecretsAtRest } from './settings' import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history' import { listTemplates, saveTemplate, removeTemplate } from './templates' import { setupPortableData } from './portable' import { safeOpenPath, safeShowInFolder } from './reveal' import { openCookieLoginWindow, getCookiesStatus, clearCookies, migrateLegacyCookies } from './cookies' import { attachEditContextMenu } from './contextMenu' import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog' import { flushAllStores } from './jsonStore' import { exportBackup, importBackup } from './backup' import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink' import { listSources, getSource, removeSource, listMediaItems, setMediaItemDownloaded, setSourceWatched } from './sources' import { indexSource } from './indexer' import { syncWatchedSources } from './sync' import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule' import { createTray, markQuitting, isQuitting } from './tray' import { getActiveBadge, getErrorBadge } from './badge' import { openPoTokenWindow } from './poToken' // Only one instance ever runs. A second launch — e.g. the OS invoking us again // for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to // this instance via 'second-instance' below, instead of opening a duplicate window. const isPrimaryInstance = app.requestSingleInstanceLock() if (!isPrimaryInstance) app.quit() // Force software compositing (GPU acceleration off). On this dev machine's old GeForce // GT 625 — and plausibly on the old / locked-down public PCs this app targets — // Chromium's GPU compositor renders blank: under hardware acceleration the WHOLE window // paints white (popup overlays alone were blank earlier). Updating the NVIDIA driver to // the last Fermi release did not fix it. Software compositing renders correctly and // costs no meaningful performance for this lightweight UI, so it is the safe default for // this app's target hardware. The only downside is a minor overlay-repaint flicker that // appears on the most broken GPUs. The window backgroundColor is theme-matched (see // createWindow); native window-occlusion tracking is disabled (a separate documented // Windows blank/flicker cause). Must run before the app is ready. app.disableHardwareAcceleration() app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion') // Redirect user data next to the exe when possible (portable, no-admin). Must run // before the app is ready and before any user path is read. setupPortableData() // Register aerofetch:// so a browser/another app can hand AeroFetch a link // (?url=) the way Android's share sheet hands Seal one. The NSIS // installer also declares this scheme (electron-builder.yml's `protocols`) // so it's registered even before first launch; this call additionally covers // the portable build and dev, which have no installer step to do it for us. const devScript = process.argv[1] if (is.dev && devScript) { app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(devScript)]) } else { app.setAsDefaultProtocolClient('aerofetch') } let mainWindow: BrowserWindow | null = null // Resolve 'system' against the OS preference so callers always get a concrete color. 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. function applyNativeTheme(theme: Settings['theme']): void { nativeTheme.themeSource = theme } function getSystemThemeInfo(): SystemThemeInfo { return { shouldUseDarkColors: nativeTheme.shouldUseDarkColors, shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors } } // Tell the user (once per run) that closing the window left AeroFetch running so // an in-progress download could finish — shown only when they haven't already // opted into tray mode, so a window that "won't close" doesn't read as a bug. let notifiedBackground = false function notifyBackgroundOnce(): void { if (notifiedBackground || !Notification.isSupported()) return notifiedBackground = true new Notification({ title: 'AeroFetch is still running', body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.', icon: getAppIconImage() }).show() } // Web permissions a download manager never needs. They're denied for the app // window as defence-in-depth (audit T6): even if the renderer were compromised // (e.g. XSS via remote video metadata) it can't open the camera/mic, read // location, or reach USB/HID/serial/Bluetooth devices — none of which the IPC // surface grants either. Clipboard (paste/copy) and everything else is left to // the default so the app's own features keep working. const DENIED_PERMISSIONS = new Set([ 'media', // camera + microphone 'geolocation', 'midi', 'midiSysex', 'hid', 'serial', 'usb', 'bluetooth', 'speaker-selection', 'idle-detection' ]) function createWindow(): void { const win = new BrowserWindow({ title: 'AeroFetch', width: 920, height: 700, // Below this the 212px sidebar + content layout breaks; pin a sensible // floor so the window can't be dragged down to unusable widths (W1). minWidth: 640, minHeight: 480, show: false, backgroundColor: PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)], autoHideMenuBar: true, webPreferences: { preload: join(__dirname, '../preload/index.cjs'), sandbox: true, contextIsolation: true, nodeIntegration: false } }) mainWindow = win // Standard Cut/Copy/Paste/Select All right-click menu on editable fields (W4). attachEditContextMenu(win.webContents) win.on('ready-to-show', () => { // A scheduled `--sync` launch starts unobtrusively (shown but not focused) so // the daily background sync doesn't steal focus; a normal launch shows + focuses. if (isSyncLaunch(process.argv)) win.showInactive() else win.show() }) // Closing the window hides to the tray (instead of quitting) when either the // user opted into background mode, OR a download is in flight — quitting would // kill the spawned yt-dlp processes and lose the download. A real quit (tray // menu / before-quit) sets isQuitting() so this lets the close through. win.on('close', (e) => { if (isQuitting()) return const downloadsRunning = hasActiveDownloads() if (getSettings().minimizeToTray || downloadsRunning) { e.preventDefault() win.hide() // If we're only staying alive because a download is running (the user // didn't opt into tray mode), tell them once — otherwise a window that // won't close looks like a bug. if (downloadsRunning && !getSettings().minimizeToTray) notifyBackgroundOnce() } }) win.on('closed', () => { if (mainWindow === win) mainWindow = null }) // When the user re-opens the window (via tray, second-instance, or deeplink) // reset the background-notify latch so they're informed again if they close // while a download is still running (L68). win.on('show', () => { notifiedBackground = false }) // The OS may have launched us with an aerofetch:// link or a "Send to" .url // file on the command line — hand it to DownloadBar's link-suggestion banner // once the page (and its IPC listener) is actually ready to receive it. const incomingUrl = extractIncomingUrl(process.argv) if (incomingUrl) { win.webContents.once('did-finish-load', () => { win.webContents.send(IpcChannels.externalUrl, incomingUrl) }) } // Open external links in the OS browser, never in-app — and only http(s), so a // file:// or custom-protocol URL can't be used to launch a local handler. win.webContents.setWindowOpenHandler((details) => { try { const { protocol } = new URL(details.url) if (protocol === 'http:' || protocol === 'https:') shell.openExternal(details.url) } catch { /* unparseable URL — ignore */ } return { action: 'deny' } }) // The app shell is local and self-contained — never let the renderer navigate // away from it (defence in depth; HMR uses websockets, not navigation). win.webContents.on('will-navigate', (e) => e.preventDefault()) // Deny the sensitive hardware/location web permissions the app never uses, so // a compromised renderer can't escalate to capabilities the IPC surface // doesn't grant. Both the async request and the sync check are covered. (audit T6) const ses = win.webContents.session ses.setPermissionRequestHandler((_wc, permission, callback) => callback(!DENIED_PERMISSIONS.has(permission)) ) ses.setPermissionCheckHandler((_wc, permission) => !DENIED_PERMISSIONS.has(permission)) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { win.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { win.loadFile(join(__dirname, '../renderer/index.html')) } } function registerIpcHandlers(): 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() }) ipcMain.handle(IpcChannels.appVersion, () => app.getVersion()) ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate()) ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) => downloadAppUpdate(url, e.sender) ) ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath)) ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion()) ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions()) ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url)) ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => { 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. if (!result.ok) { 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, (e, id: string, args: string) => 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. openCookieLoginWindow(url, BrowserWindow.fromWebContents(e.sender) ?? undefined) ) 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.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()) 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; see ROADMAP-PINCHFLAT.md) --- 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) => setMediaItemDownloaded(id, filePath) ) // Indexing pushes live progress to the requesting renderer over `indexProgress` // and resolves with the final result. ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) => indexSource(url, (p) => { if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p) }) ) ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => { const src = getSource(id) if (!src) return { ok: false, error: 'Source not found.' } return indexSource(src.url, (p) => { if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p) }) }) ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) => setSourceWatched(id, watched) ) ipcMain.handle(IpcChannels.sourcesSync, (e) => syncWatchedSources((p) => { if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p) }) ) ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync()) ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled)) // Reflect overall queue progress on the Windows taskbar. ipcMain.handle(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => { if (!mainWindow || mainWindow.isDestroyed()) return if (p.mode === 'none') { mainWindow.setProgressBar(-1) mainWindow.setOverlayIcon(null, '') } else { mainWindow.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` mainWindow.setOverlayIcon(badge, 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() if (token) await setSettings({ youtubePoToken: token }) return token }) } // Push OS theme/contrast changes to every window, and keep the native // background in sync for windows currently following 'system'. function registerSystemThemeBridge(): void { nativeTheme.on('updated', () => { const info = getSystemThemeInfo() const bg = PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)] for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(IpcChannels.systemThemeUpdate, info) if (getSettings().theme === 'system') win.setBackgroundColor(bg) } }) } if (isPrimaryInstance) { // A second launch arrives here as argv, not a real new process — extract any // link it carried and hand it to the window we already have. app.on('second-instance', (_e, argv) => { if (!mainWindow) return focusWindow(mainWindow) const incomingUrl = extractIncomingUrl(argv) if (incomingUrl) mainWindow.webContents.send(IpcChannels.externalUrl, incomingUrl) }) app.whenReady().then(() => { electronApp.setAppUserModelId('com.aerofetch.app') // M31: suppress the default Electron menu (which exposes DevTools/Reload via // Alt) in production builds. In dev the menu is kept so DevTools are accessible. if (!is.dev) Menu.setApplicationMenu(null) // Create the default Documents\Video and Documents\Audio destinations so they // exist from first launch (downloads are routed into them by kind). ensureMediaDirs() // Encrypt any credential still stored as legacy plaintext (from before at-rest // encryption), once safeStorage is available post-ready. migrateSecretsAtRest() // Same for a pre-encryption plaintext cookies.txt — encrypt it and delete the // plaintext so a logged-in session no longer sits in the open (H7). migrateLegacyCookies() // Sync the Windows "run at sign-in" entry with the persisted setting, so it // reflects the user's choice even if they changed it on another install. applyLaunchAtStartup(getSettings().launchAtStartup) app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) registerIpcHandlers() registerSystemThemeBridge() registerSendToShortcut() // Apply the persisted theme to the OS title bar before the window opens (W3). applyNativeTheme(getSettings().theme) createWindow() createTray(() => mainWindow) // Taskbar right-click jump list: a quick "Open AeroFetch" task. Launching the // exe again is caught by the single-instance lock, which focuses this window. app.setUserTasks([ { program: process.execPath, arguments: '', title: 'Open AeroFetch', description: 'Open the AeroFetch window', iconPath: process.execPath, iconIndex: 0 } ]) // Keep yt-dlp current on its own: seed the managed copy, then (throttled to // once a day) self-update it to the chosen channel so a stale binary can't // silently cause YouTube 403s. Best-effort and off the critical path — it // never blocks startup, and status is pushed to the window if it's listening. runStartupYtdlpAutoUpdate((status) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status) } }).catch((e) => console.error('[AeroFetch] yt-dlp auto-update failed:', e)) }) // Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set // the quitting flag so the window's close handler exits instead of hiding to tray. // Also flush any debounced JSON-store writes synchronously so a quit mid-debounce // can't drop the last history/sources/template change (R3). app.on('before-quit', () => { markQuitting() flushAllStores() }) // Windows-only app: closing the last window quits (the tray/in-flight-download // paths hide rather than close, so this only fires on a real exit). The former // macOS 'activate' handler and darwin guard were dead branches here (L147). app.on('window-all-closed', () => app.quit()) }