3df32d0505
L22: DownloadOptionsForm accepts optional `kind` prop. When set, hides
audio-specific fields (Audio format) for video downloads and vice versa
(Video container, Preferred codec) for audio downloads. Settings passes
defaultKind so the defaults card shows only relevant options.
L24: Replace lone window.open('htmlUrl', '_blank') with a new openUrl IPC
channel (shell:open-url) that calls shell.openExternal after validating
the protocol is http/https. Consistent with how all other external opens
work in the app. Preload + main handler + mock updated.
L27: DownloadBar URL-field Enter is now smart -- if formats/playlist are
already loaded (or probe errored), Enter triggers download; otherwise
it probes. Eliminates the keyboard dead-end where Enter never downloads.
typecheck + 242 tests + eslint green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
557 lines
23 KiB
TypeScript
557 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.
|
|
ipcMain.handle(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
|
|
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
if (p.mode === 'none') {
|
|
mainWindow.setProgressBar(-1)
|
|
mainWindow.setOverlayIcon(null, '')
|
|
} 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)
|
|
}
|
|
})
|
|
|
|
// 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())
|
|
}
|