fa78b13cac
Adds theme presets (Toffee/Slate/Evergreen/Lavender) with a "follow system" mode and high-contrast awareness, a first-run welcome screen, and the Windows analog of Android's share sheet for a Win32 app: an aerofetch:// protocol handler plus an Explorer "Send to AeroFetch" entry, both routed through a single-instance lock so a second launch hands its link to the already-running window instead of opening a duplicate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
280 lines
11 KiB
TypeScript
280 lines
11 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
|
|
} from '@shared/ipc'
|
|
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
|
import { probeMedia } from './probe'
|
|
import { startDownload, cancelDownload, previewCommand } from './download'
|
|
import { getSettings, setSettings } 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'
|
|
|
|
// 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: '#f6f1ef', 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
|
|
}
|
|
}
|
|
|
|
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', () => {
|
|
win.show()
|
|
})
|
|
|
|
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())
|
|
|
|
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.ytdlpVersion, () => getYtdlpVersion())
|
|
|
|
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.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
|
|
})
|
|
}
|
|
|
|
// 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')
|
|
|
|
app.on('browser-window-created', (_, window) => {
|
|
optimizer.watchWindowShortcuts(window)
|
|
})
|
|
|
|
registerIpcHandlers()
|
|
registerSystemThemeBridge()
|
|
registerSendToShortcut()
|
|
createWindow()
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
})
|
|
})
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') {
|
|
app.quit()
|
|
}
|
|
})
|
|
}
|