Harden audit findings: correctness, type-safety, Windows conventions & polish
Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified — typecheck + 234 tests + eslint + prettier green). Correctness / bugs: - B3: match the release checksum to the asset's filename line (no wrong-hash verify) - B4: newline-safe metadata probe (one --print with a unit-separator delimiter) - B5 / L88: guard the meta event against canceled items; progress no longer promotes a queued item outside pump() - B7: cookie-login promise always resolves (handles destroy-without-close) - L146: trim parser rejects >2 colon-group times; M36: Library selection counts only actionable rows - L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message, schedule picker min, dead-code/comment cleanup Type safety: - Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed) Resilience / Windows / metadata: - R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth) - W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window; L147 dead macOS branches removed - CL1: shared stdout markers; package/builder metadata (license, homepage, repository, copyright, tsbuildinfo glob) Copy / docs / tests: - M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled - New unit tests for L35 (isValidMediaItem) and L36 (compareVersions) This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard work it builds on: background running + auto-download, library clipboard detection, tray, binary management & library scale, credential encryption at rest, the shared jsonStore and ui/ primitives, and the eslint/prettier tooling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+117
-34
@@ -1,5 +1,16 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Notification } from 'electron'
|
||||
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,
|
||||
@@ -15,6 +26,7 @@ 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,
|
||||
@@ -34,8 +46,15 @@ import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory
|
||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
import { setupPortableData } from './portable'
|
||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
||||
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 {
|
||||
@@ -50,6 +69,8 @@ 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
|
||||
@@ -79,8 +100,9 @@ setupPortableData()
|
||||
// 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])])
|
||||
const devScript = process.argv[1]
|
||||
if (is.dev && devScript) {
|
||||
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(devScript)])
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient('aerofetch')
|
||||
}
|
||||
@@ -93,13 +115,18 @@ let mainWindow: BrowserWindow | null = null
|
||||
// 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).
|
||||
// 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,
|
||||
@@ -116,7 +143,8 @@ function notifyBackgroundOnce(): void {
|
||||
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.'
|
||||
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.',
|
||||
icon: getAppIconImage()
|
||||
}).show()
|
||||
}
|
||||
|
||||
@@ -143,6 +171,10 @@ function createWindow(): void {
|
||||
const win = new BrowserWindow({
|
||||
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: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
|
||||
autoHideMenuBar: true,
|
||||
@@ -155,6 +187,9 @@ function createWindow(): void {
|
||||
})
|
||||
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.
|
||||
@@ -226,6 +261,16 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -262,13 +307,17 @@ function registerIpcHandlers(): void {
|
||||
)
|
||||
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
|
||||
|
||||
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
||||
|
||||
ipcMain.handle(IpcChannels.chooseFolder, async (e) => {
|
||||
ipcMain.handle(IpcChannels.chooseFolder, async (e, current?: string) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
||||
const res = await dialog.showOpenDialog(win!, {
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
// 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]
|
||||
})
|
||||
|
||||
@@ -280,13 +329,14 @@ function registerIpcHandlers(): void {
|
||||
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).
|
||||
// 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(
|
||||
THEME_BACKGROUND[resolveBackgroundMode(result.theme)]
|
||||
)
|
||||
applyNativeTheme(result.theme)
|
||||
}
|
||||
return result
|
||||
})
|
||||
@@ -303,12 +353,18 @@ function registerIpcHandlers(): void {
|
||||
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.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.templatesSave, (_e, template: CommandTemplate) =>
|
||||
saveTemplate(template)
|
||||
)
|
||||
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
|
||||
|
||||
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
|
||||
@@ -327,9 +383,11 @@ function registerIpcHandlers(): void {
|
||||
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.
|
||||
// background and title bar in sync the same way settingsSet does (W3).
|
||||
if (result.ok) {
|
||||
win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)])
|
||||
const theme = getSettings().theme
|
||||
win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(theme)])
|
||||
applyNativeTheme(theme)
|
||||
}
|
||||
return result
|
||||
})
|
||||
@@ -369,8 +427,24 @@ function registerIpcHandlers(): void {
|
||||
// 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 })
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,6 +474,10 @@ if (isPrimaryInstance) {
|
||||
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()
|
||||
@@ -407,6 +485,9 @@ if (isPrimaryInstance) {
|
||||
// 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.
|
||||
@@ -419,6 +500,8 @@ if (isPrimaryInstance) {
|
||||
registerIpcHandlers()
|
||||
registerSystemThemeBridge()
|
||||
registerSendToShortcut()
|
||||
// Apply the persisted theme to the OS title bar before the window opens (W3).
|
||||
applyNativeTheme(getSettings().theme)
|
||||
createWindow()
|
||||
createTray(() => mainWindow)
|
||||
|
||||
@@ -443,20 +526,20 @@ if (isPrimaryInstance) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
}).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.
|
||||
app.on('before-quit', () => markQuitting())
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
// 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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user