Files
AeroFetch/src/main/index.ts
T
debont80 20ee913394 feat: Phase O Windows-native integration + settings search
- Taskbar progress bar: renderer pushes summarizeQueue aggregate over a new
  taskbar:progress channel (App store subscription); setProgressBar normal/error
- System tray (src/main/tray.ts) + minimize-to-tray (Settings.minimizeToTray);
  close hides to tray, isQuitting guards real exits; icon via getAppIconPath()
  (build/icon.ico added to extraResources)
- Jump list: app.setUserTasks "Open AeroFetch" (thumbnail toolbar deferred)
- Settings search: filters the ~11 SettingsView cards live by text match

Overlay badge deferred (needs a drawn NativeImage). typecheck + test (192) +
build all clean. Roadmap: Phase O COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:20:28 -04:00

422 lines
17 KiB
TypeScript

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,
type TaskbarProgress
} from '@shared/ipc'
import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp'
import { getFfmpegVersions } from './ffmpeg'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
import { runTerminal, cancelTerminal } from './terminal'
import { getSettings, setSettings, ensureMediaDirs } 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'
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'
// 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.
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: '#f7f7f8', 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
}
}
// 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({
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', () => {
// 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()
})
// Minimize-to-tray: when enabled, closing the window hides it to the tray
// instead of quitting. A real quit (tray menu / before-quit) sets isQuitting().
win.on('close', (e) => {
if (getSettings().minimizeToTray && !isQuitting()) {
e.preventDefault()
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'))
}
}
function registerIpcHandlers(): void {
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.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<Settings>) => {
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
})
// --- 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 (fire-and-forget).
ipcMain.on(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
if (!mainWindow || mainWindow.isDestroyed()) return
if (p.mode === 'none') mainWindow.setProgressBar(-1)
else mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
})
}
// 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')
// Create the default Documents\Video and Documents\Audio destinations so they
// exist from first launch (downloads are routed into them by kind).
ensureMediaDirs()
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
registerSystemThemeBridge()
registerSendToShortcut()
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(() => {})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// 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.
app.on('before-quit', () => markQuitting())
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
}