Files
AeroFetch/src/main/index.ts
T
debont80 036ef74362 Fix SR8: update window title to reflect active download state
The window title was always 'AeroFetch' regardless of activity, so screen
readers and taskbar previews couldn't tell whether downloads were running.

The taskbarProgress IPC handler (which already fires on each progress tick)
now calls mainWindow.setTitle() alongside setProgressBar/setOverlayIcon:
- Idle: 'AeroFetch'
- Downloading: 'AeroFetch -- N downloads active'
- Error: 'AeroFetch -- Error'

This reuses the same overlay badge label string so the text is consistent
between the taskbar tooltip and the title bar.

typecheck + 242 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:41:15 -04:00

559 lines
23 KiB
TypeScript

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=<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
// 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<Settings>) => {
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 and window title (SR8).
ipcMain.handle(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
if (!mainWindow || mainWindow.isDestroyed()) return
if (p.mode === 'none') {
mainWindow.setProgressBar(-1)
mainWindow.setOverlayIcon(null, '')
mainWindow.setTitle('AeroFetch')
} 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)
mainWindow.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()
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())
}