import { app, shell, BrowserWindow, nativeTheme, dialog, Menu } from 'electron' import { join, resolve } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { IpcChannels } from '@shared/ipc' import { PAGE_BACKGROUND } from '@shared/theme' import { registerIpcHandlers, resolveBackgroundMode, applyNativeTheme, getSystemThemeInfo } from './ipc' import { runStartupYtdlpAutoUpdate } from './ytdlp' import { hasActiveDownloads } from './download' import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings' import { ensureMediaDirs } from './paths' import { setupPortableData } from './portable' import { migrateLegacyCookies } from './cookies' import { attachEditContextMenu } from './contextMenu' import { flushAllStores } from './jsonStore' import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink' import { isSyncLaunch } from './schedule' import { createTray, markQuitting, isQuitting } from './tray' import { logger } from './logger' import { initialWindowState, saveWindowState } from './windowState' // 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() // 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 — including the // settings read for the hardware-acceleration gate just below. setupPortableData() // Software compositing (GPU acceleration off) is the DEFAULT, now behind an // opt-in setting (W15). 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 stays the // safe default; users on modern GPUs can flip Settings → Appearance → // "Hardware acceleration" (applies on next launch — this must run before the // app is ready). The window backgroundColor is theme-matched (see createWindow). if (!getSettings().useHardwareAcceleration) { app.disableHardwareAcceleration() } // Native window-occlusion tracking stays off in BOTH modes — it's a separate // documented Windows blank/flicker cause, independent of GPU compositing. app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion') // 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 // 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. // L143: an in-window, tray-INDEPENDENT way to quit at the moment the window "won't // close" because a download is running — without it the only exit is the tray menu // (and Task Manager if the tray ever failed to create). Guarded so hammering the X // can't stack dialogs. Only used when the user did NOT opt into tray mode; a // deliberate minimize-to-tray close stays a silent hide. let quitPromptOpen = false function promptQuitWhileDownloading(win: BrowserWindow): void { if (quitPromptOpen) return quitPromptOpen = true dialog .showMessageBox(win, { type: 'question', buttons: ['Keep downloading', 'Quit anyway'], defaultId: 0, cancelId: 0, title: 'Download in progress', message: 'A download is still in progress.', detail: 'AeroFetch will keep downloading in the background — reopen it from the tray icon. Quit anyway to stop the download and exit now.' }) .then(({ response }) => { quitPromptOpen = false if (response === 1) { markQuitting() // lets the close handler through + runs the before-quit teardown app.quit() } else { win.hide() // keep downloading in the background } }) .catch(() => { quitPromptOpen = false win.hide() }) } // 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 { // Reopen where the user left off (size / position / maximized), falling back to the // default size when there's no usable saved state — first run, or the saved monitor // is no longer connected (W2 / UX19). const ws = initialWindowState() const win = new BrowserWindow({ title: 'AeroFetch', width: ws.width, height: ws.height, x: ws.x, y: ws.y, // 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 if (ws.maximized) win.maximize() // Persist size/position/maximized so the next launch restores them (W2). Debounced // so a drag-resize writes once when it settles; also saved on close (which may only // hide to tray) to capture the final state. let saveTimer: ReturnType | null = null const scheduleSave = (): void => { if (saveTimer) clearTimeout(saveTimer) saveTimer = setTimeout(() => saveWindowState(win), 500) } win.on('resize', scheduleSave) win.on('move', scheduleSave) // 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) => { // Capture the final bounds even when the close only hides to tray (W2). saveWindowState(win) if (isQuitting()) return const downloadsRunning = hasActiveDownloads() if (getSettings().minimizeToTray || downloadsRunning) { e.preventDefault() // A download (not tray mode) is the only thing holding the app open: offer a // tray-independent "Quit anyway" instead of silently hiding — otherwise a // window that won't close looks like a bug, with no in-window way out (L143). // A tray-mode close is the user's explicit choice, so that stays a silent hide. if (downloadsRunning && !getSettings().minimizeToTray) { promptQuitWhileDownloading(win) } else { win.hide() } } }) 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()) // 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')) } } // 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(() => mainWindow) 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) => logger.error('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()) }