import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron' import { join, resolve } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { IpcChannels, type StartDownloadOptions, type Settings, type HistoryEntry, type CommandTemplate, type YtdlpUpdateChannel, type SystemThemeInfo } from '@shared/ipc' import { getYtdlpVersion, updateYtdlp } from './ytdlp' import { probeMedia } from './probe' import { startDownload, cancelDownload, previewCommand } from './download' import { getSettings, setSettings } 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 } from './cookies' import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog' import { exportBackup, importBackup } from './backup' import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink' // 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. if (is.dev && process.argv.length >= 2) { app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(process.argv[1])]) } else { app.setAsDefaultProtocolClient('aerofetch') } let mainWindow: BrowserWindow | null = null // Page background per theme — keep in sync with `pageBackground` in // src/renderer/src/theme.ts. Set as the window's NATIVE background so the // one-frame compositor repaint (when a tooltip/dropdown overlay first paints on // Windows) shows the app's current color instead of a mismatched white flash. const THEME_BACKGROUND = { light: '#f6f1ef', dark: '#161618' } as const // 'system' isn't a real background — resolve it against the OS's current // preference (nativeTheme.themeSource defaults to 'system', so this tracks it // without AeroFetch ever touching themeSource itself). function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' { return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme } function getSystemThemeInfo(): SystemThemeInfo { return { shouldUseDarkColors: nativeTheme.shouldUseDarkColors, shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors } } function createWindow(): void { const win = new BrowserWindow({ width: 920, height: 700, show: false, backgroundColor: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)], autoHideMenuBar: true, webPreferences: { preload: join(__dirname, '../preload/index.cjs'), sandbox: true, contextIsolation: true, nodeIntegration: false } }) mainWindow = win win.on('ready-to-show', () => { win.show() }) win.on('closed', () => { if (mainWindow === win) mainWindow = null }) // 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()) 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 { ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion()) 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.defaultFolder, () => app.getPath('downloads')) ipcMain.handle(IpcChannels.chooseFolder, async (e) => { const win = BrowserWindow.fromWebContents(e.sender) ?? undefined const res = await dialog.showOpenDialog(win!, { properties: ['openDirectory', 'createDirectory'] }) return res.canceled || !res.filePaths[0] ? null : res.filePaths[0] }) ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p)) 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 in sync with the theme so a compositor // repaint never flashes a mismatched color behind an overlay. Use the // validated result, not the raw partial (which may hold a bogus value). if (partial.theme) { BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor( THEME_BACKGROUND[resolveBackgroundMode(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) => openCookieLoginWindow(url)) 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 in sync the same way settingsSet does. if (result.ok) { win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)]) } return result }) } // 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 = THEME_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') app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) registerIpcHandlers() registerSystemThemeBridge() registerSendToShortcut() createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) }