Files
AeroFetch/src/main/index.ts
T
debont80 eb53de2ea5 feat(setup): fetch ffmpeg on first run instead of bundling it
Ship a smaller installer that no longer carries ffmpeg.exe/ffprobe.exe
(the bulk of its size). On first run they are downloaded from a pinned
upstream archive (gyan 8.1.2), verified against a pinned SHA-256, and
unpacked with the OS tar.exe into the managed userData/bin dir -- the
same place as the self-updating yt-dlp, so they survive app updates and
portable re-extraction. A hard onboarding gate blocks the app until they
are present, with a "locate existing ffmpeg" folder-picker fallback for
offline machines and a Repair action in Settings > About.

The updater's streaming/checksum/redirect/idle-timeout download loop is
extracted to lib/verifiedDownload.streamVerifiedFile and shared by both
the app-installer download and the ffmpeg fetch; the updater's public
behaviour and error strings are unchanged (its boundary tests still pass).
No new npm dependency: extraction uses the System32 bsdtar resolved by
absolute path (audit F3).

Note: this commit also carries pre-existing, in-progress aria2c/network
and updater-token work that was already uncommitted in the working tree
and is entangled with the above in shared files (updater.ts, ipc.ts,
preload/index.ts, mockApi.ts, shared/ipc.ts, plus the settings/network
files and aria2c.exe). It was not cleanly separable by path, so it is
included here rather than split out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:02:13 -04:00

346 lines
15 KiB
TypeScript

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 { ensureManagedFfmpeg } from './ffmpegSetup'
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=<encoded>) 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<typeof setTimeout> | 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
}
])
// Seed the managed ffmpeg/ffprobe from the dev bundle when present, so a dev checkout
// (and any transitional build that still ships them) is ready without a download. In a
// shipped installer there's no seed, so this no-ops and the renderer's setup gate
// triggers the first-run download instead. Cheap + best-effort.
ensureManagedFfmpeg()
// 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())
}