Format code with Prettier (8 modified files from H1 decomposition)
Aligns with CC2 enforcement: ESLint + Prettier + noImplicitAny now enforced. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+23
-8
@@ -370,14 +370,29 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
||||
return args
|
||||
}
|
||||
|
||||
export function buildArgs(
|
||||
opts: StartDownloadOptions,
|
||||
outputTemplate: string,
|
||||
o: DownloadOptions,
|
||||
binDir: string,
|
||||
access: AccessOptions,
|
||||
extraArgs: string[] = []
|
||||
): string[] {
|
||||
/**
|
||||
* Everything buildArgs needs to construct a yt-dlp argv. A single options object
|
||||
* (rather than six positional params) so callers can't transpose `opts`/`options`
|
||||
* or the two path-like strings, and new inputs can be added without churning every
|
||||
* call site (CL2).
|
||||
*/
|
||||
export interface BuildArgsInput {
|
||||
/** The per-download request (url, kind, quality, chosen format, trim, …). */
|
||||
opts: StartDownloadOptions
|
||||
/** The resolved `-o` output template (flat filename or collection folder tree). */
|
||||
outputTemplate: string
|
||||
/** The post-processing options group (per-download override or the persisted default). */
|
||||
options: DownloadOptions
|
||||
/** ffmpeg/yt-dlp bin dir, passed in so this module stays free of path resolution. */
|
||||
binDir: string
|
||||
/** Global access/networking settings (proxy, cookies, rate limit, …). */
|
||||
access: AccessOptions
|
||||
/** Custom-command extra args, already consent-gated by the caller. */
|
||||
extraArgs?: string[]
|
||||
}
|
||||
|
||||
export function buildArgs(input: BuildArgsInput): string[] {
|
||||
const { opts, outputTemplate, options: o, binDir, access, extraArgs = [] } = input
|
||||
const args = [
|
||||
'--newline',
|
||||
'--no-color',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Central home for the main-process timeouts, buffer sizes, and store caps that
|
||||
* were previously scattered as bare literals across the spawn/probe/index/store
|
||||
* modules (L10). Collecting them here makes the operational envelope reviewable
|
||||
* in one place and stops the same "how long / how big" decision drifting between
|
||||
* modules. Values that are already single-sourced in the shared contract
|
||||
* (e.g. HISTORY_MAX_ENTRIES) stay there; this file is for the main-only ones.
|
||||
*/
|
||||
|
||||
// --- Child-process timeouts (ms) --------------------------------------------
|
||||
|
||||
/** `<binary> -version` probes (yt-dlp, ffmpeg, ffprobe) — a quick liveness call. */
|
||||
export const VERSION_TIMEOUT_MS = 15_000
|
||||
/** yt-dlp self-update run — can pull a fresh binary, so a touch longer. */
|
||||
export const YTDLP_UPDATE_TIMEOUT_MS = 60_000
|
||||
/** Best-effort metadata --print alongside a download (title/uploader/duration). */
|
||||
export const META_PROBE_TIMEOUT_MS = 30_000
|
||||
/** Full format/metadata probe (`-J`) for the download bar. */
|
||||
export const PROBE_TIMEOUT_MS = 60_000
|
||||
/** Channel/playlist indexing walk — a big channel legitimately takes minutes. */
|
||||
export const INDEX_TIMEOUT_MS = 180_000
|
||||
/** RSS feed fetch for a watched source's new-item check. */
|
||||
export const FEED_FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Idle watchdog for a running download: if a spawned yt-dlp emits no stdout or
|
||||
* stderr for this long, treat it as wedged and kill it so the concurrency slot
|
||||
* frees (B1). Generous on purpose so a long, output-less post-processing step
|
||||
* (e.g. a large ffmpeg merge) isn't mistaken for a stall.
|
||||
*/
|
||||
export const STALL_TIMEOUT_MS = 5 * 60_000
|
||||
|
||||
// --- execFile maxBuffer sizes (bytes) ---------------------------------------
|
||||
// yt-dlp JSON for a big channel/format list is large; cap generously so a valid
|
||||
// response is never truncated (which would look like a parse failure).
|
||||
|
||||
/** Metadata --print output (three short fields). */
|
||||
export const META_MAX_BUFFER = 4 * 1024 * 1024
|
||||
/** Single-video `-J` probe JSON. */
|
||||
export const PROBE_MAX_BUFFER = 64 * 1024 * 1024
|
||||
/** Whole-channel index JSON. */
|
||||
export const INDEX_MAX_BUFFER = 256 * 1024 * 1024
|
||||
|
||||
// --- Misc ------------------------------------------------------------------
|
||||
|
||||
/** How much of a failed download's stderr to retain for the error message. */
|
||||
export const STDERR_TAIL_BYTES = 4000
|
||||
|
||||
// --- JSON-store row caps ----------------------------------------------------
|
||||
// Each hand-rolled JSON store trims to its cap on write so a store file can't
|
||||
// grow without bound.
|
||||
|
||||
/** Diagnostics error log (errorlog.json). */
|
||||
export const ERRORLOG_MAX_ENTRIES = 200
|
||||
/** Saved custom-command templates (templates.json). */
|
||||
export const TEMPLATES_MAX = 100
|
||||
/** Indexed media items across all sources (media-items.json). */
|
||||
export const MEDIA_ITEMS_MAX = 20_000
|
||||
+49
-24
@@ -13,7 +13,8 @@ import {
|
||||
getAppIconImage,
|
||||
YTDLP_MISSING_MSG
|
||||
} from './binaries'
|
||||
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
||||
import { getSettings } from './settings'
|
||||
import { getDownloadArchivePath, getDefaultMediaDir } from './paths'
|
||||
import { ensureManagedYtdlp } from './ytdlp'
|
||||
import { materializeCookies, hasStoredCookies } from './cookies'
|
||||
import { listTemplates } from './templates'
|
||||
@@ -29,6 +30,12 @@ import {
|
||||
} from './buildArgs'
|
||||
import { cleanError } from './log'
|
||||
import { addErrorLog } from './errorlog'
|
||||
import {
|
||||
STALL_TIMEOUT_MS,
|
||||
META_PROBE_TIMEOUT_MS,
|
||||
META_MAX_BUFFER,
|
||||
STDERR_TAIL_BYTES
|
||||
} from './constants'
|
||||
import {
|
||||
IpcChannels,
|
||||
type StartDownloadOptions,
|
||||
@@ -62,14 +69,6 @@ function releaseActive(id: string, rec: ActiveDownload): void {
|
||||
if (active.get(id) === rec) active.delete(id)
|
||||
}
|
||||
|
||||
// Backstop for B1: if a spawned yt-dlp goes completely silent — no stdout or
|
||||
// stderr — for this long, treat it as wedged, kill it, and error the item so the
|
||||
// concurrency slot frees instead of leaking forever. Generous on purpose so a
|
||||
// long, output-less post-processing step (e.g. a large ffmpeg merge) isn't
|
||||
// mistaken for a stall; --socket-timeout (buildArgs) handles the common
|
||||
// dead-connection case at the network layer.
|
||||
const STALL_TIMEOUT_MS = 5 * 60_000
|
||||
|
||||
/**
|
||||
* Whether any yt-dlp download is currently running. Used by the window's close
|
||||
* handler to keep the app alive in the tray (instead of quitting and killing the
|
||||
@@ -137,7 +136,7 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
||||
'--',
|
||||
url
|
||||
],
|
||||
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 },
|
||||
{ windowsHide: true, maxBuffer: META_MAX_BUFFER, timeout: META_PROBE_TIMEOUT_MS },
|
||||
(err, stdout) => {
|
||||
if (err) return resolve(null)
|
||||
const [title, uploader, duration] = stdout.split(META_SEP).map((l) => l.trim())
|
||||
@@ -211,7 +210,7 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string):
|
||||
youtubePoToken: settings.youtubePoToken
|
||||
}
|
||||
const extraArgs = resolveExtraArgs(opts, settings)
|
||||
return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs)
|
||||
return buildArgs({ opts, outputTemplate, options, binDir: getBinDir(), access, extraArgs })
|
||||
}
|
||||
|
||||
/** Build the exact command line for the current form state, without running it. */
|
||||
@@ -333,6 +332,34 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
|
||||
})
|
||||
}
|
||||
|
||||
// Wire the child's streams + teardown (CL3). resolvedTitle is passed as a getter
|
||||
// because the parallel probeMeta above may fill it in after this returns.
|
||||
wireChildProcess({ wc, opts, rec, cleanup: cleanupCookies, getTitle: () => resolvedTitle })
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// --- Child-process wiring ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wire a spawned yt-dlp child's stdout/stderr/close/error to download events, and
|
||||
* run the B1 idle watchdog. Extracted from startDownload so that function reads as
|
||||
* a linear spawn + pre-flight and this owns the streaming/teardown lifecycle (CL3).
|
||||
*
|
||||
* `getTitle` is read lazily on each event: the completion/error paths need the
|
||||
* best title known *at settle time*, which the parallel metadata probe may only
|
||||
* fill in after wiring is set up.
|
||||
*/
|
||||
function wireChildProcess(params: {
|
||||
wc: WebContents
|
||||
opts: StartDownloadOptions
|
||||
rec: ActiveDownload
|
||||
cleanup: () => void
|
||||
getTitle: () => string | undefined
|
||||
}): void {
|
||||
const { wc, opts, rec, cleanup, getTitle } = params
|
||||
const child = rec.child
|
||||
|
||||
let stdoutBuf = ''
|
||||
let stderrTail = ''
|
||||
let filePath: string | undefined
|
||||
@@ -360,13 +387,13 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
|
||||
if (settled || rec.canceled || rec.paused) return
|
||||
settled = true
|
||||
clearWatchdog()
|
||||
cleanupCookies()
|
||||
cleanup()
|
||||
releaseActive(opts.id, rec)
|
||||
killTree(rec)
|
||||
const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.`
|
||||
send(wc, { type: 'error', id: opts.id, error: msg })
|
||||
logFailure(opts, resolvedTitle, msg)
|
||||
notify(wc, resolvedTitle ?? 'Download failed', msg)
|
||||
logFailure(opts, getTitle(), msg)
|
||||
notify(wc, getTitle() ?? 'Download failed', msg)
|
||||
}, STALL_TIMEOUT_MS)
|
||||
}
|
||||
bumpWatchdog()
|
||||
@@ -397,20 +424,20 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
|
||||
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
bumpWatchdog()
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-4000)
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_BYTES)
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearWatchdog()
|
||||
cleanupCookies()
|
||||
cleanup()
|
||||
releaseActive(opts.id, rec)
|
||||
// A paused download was killed on purpose — stay silent, like a cancel.
|
||||
if (!rec.canceled && !rec.paused) {
|
||||
send(wc, { type: 'error', id: opts.id, error: err.message })
|
||||
logFailure(opts, resolvedTitle, err.message)
|
||||
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
||||
logFailure(opts, getTitle(), err.message)
|
||||
notify(wc, getTitle() ?? 'Download failed', err.message)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -418,23 +445,21 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearWatchdog()
|
||||
cleanupCookies()
|
||||
cleanup()
|
||||
releaseActive(opts.id, rec)
|
||||
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
||||
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
||||
if (rec.canceled || rec.paused) return
|
||||
if (code === 0) {
|
||||
send(wc, { type: 'done', id: opts.id, filePath })
|
||||
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
||||
notify(wc, getTitle() ?? 'Download complete', 'Finished downloading.')
|
||||
} else {
|
||||
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
|
||||
send(wc, { type: 'error', id: opts.id, error: msg })
|
||||
logFailure(opts, resolvedTitle, msg)
|
||||
notify(wc, resolvedTitle ?? 'Download failed', msg)
|
||||
logFailure(opts, getTitle(), msg)
|
||||
notify(wc, getTitle() ?? 'Download failed', msg)
|
||||
}
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { ErrorLogEntry } from '@shared/ipc'
|
||||
import { isValidErrorLogEntry } from './validation'
|
||||
import { createJsonStore } from './jsonStore'
|
||||
import { ERRORLOG_MAX_ENTRIES } from './constants'
|
||||
|
||||
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
|
||||
// report survives the queue item being cleared (Seal's "debug report"). Atomic
|
||||
// writes / corruption backup / caching come from the shared jsonStore (R1–R3).
|
||||
const MAX_ENTRIES = 200
|
||||
|
||||
// Per-entry validation (isValidErrorLogEntry) so a hand-edited or corrupted
|
||||
// errorlog.json can't feed the UI entries with the wrong shape. (audit S5)
|
||||
const store = createJsonStore('errorlog.json', isValidErrorLogEntry, MAX_ENTRIES)
|
||||
const store = createJsonStore('errorlog.json', isValidErrorLogEntry, ERRORLOG_MAX_ENTRIES)
|
||||
|
||||
export function listErrorLog(): ErrorLogEntry[] {
|
||||
return store.read()
|
||||
|
||||
+14
-8
@@ -1,6 +1,7 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { getFfmpegPath, getFfprobePath } from './binaries'
|
||||
import { VERSION_TIMEOUT_MS } from './constants'
|
||||
import type { FfmpegVersionResult } from '@shared/ipc'
|
||||
|
||||
/**
|
||||
@@ -14,15 +15,20 @@ import type { FfmpegVersionResult } from '@shared/ipc'
|
||||
function readToolVersion(path: string): Promise<string | null> {
|
||||
if (!existsSync(path)) return Promise.resolve(null)
|
||||
return new Promise((resolve) => {
|
||||
execFile(path, ['-version'], { windowsHide: true, timeout: 15_000 }, (err, stdout) => {
|
||||
if (err) {
|
||||
resolve(null)
|
||||
return
|
||||
execFile(
|
||||
path,
|
||||
['-version'],
|
||||
{ windowsHide: true, timeout: VERSION_TIMEOUT_MS },
|
||||
(err, stdout) => {
|
||||
if (err) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const firstLine = stdout.split('\n', 1)[0] ?? ''
|
||||
const m = firstLine.match(/version\s+(\S+)/i)
|
||||
resolve(m?.[1] ?? null)
|
||||
}
|
||||
const firstLine = stdout.split('\n', 1)[0] ?? ''
|
||||
const m = firstLine.match(/version\s+(\S+)/i)
|
||||
resolve(m?.[1] ?? null)
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+15
-283
@@ -1,77 +1,26 @@
|
||||
import {
|
||||
app,
|
||||
shell,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
dialog,
|
||||
clipboard,
|
||||
nativeTheme,
|
||||
Notification,
|
||||
Menu
|
||||
} from 'electron'
|
||||
import { app, shell, BrowserWindow, 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 { IpcChannels } 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 {
|
||||
registerIpcHandlers,
|
||||
resolveBackgroundMode,
|
||||
applyNativeTheme,
|
||||
getSystemThemeInfo
|
||||
} from './ipc'
|
||||
import { runStartupYtdlpAutoUpdate } from './ytdlp'
|
||||
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 { hasActiveDownloads } from './download'
|
||||
import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings'
|
||||
import { ensureMediaDirs } from './paths'
|
||||
import { setupPortableData } from './portable'
|
||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||
import {
|
||||
openCookieLoginWindow,
|
||||
getCookiesStatus,
|
||||
clearCookies,
|
||||
migrateLegacyCookies
|
||||
} from './cookies'
|
||||
import { 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 { 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
|
||||
@@ -110,25 +59,6 @@ if (is.dev && devScript) {
|
||||
|
||||
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.
|
||||
@@ -263,204 +193,6 @@ 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())
|
||||
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 {
|
||||
@@ -510,7 +242,7 @@ if (isPrimaryInstance) {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
registerIpcHandlers()
|
||||
registerIpcHandlers(() => mainWindow)
|
||||
registerSystemThemeBridge()
|
||||
registerSendToShortcut()
|
||||
// Apply the persisted theme to the OS title bar before the window opens (W3).
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
||||
import { INDEX_MAX_BUFFER, INDEX_TIMEOUT_MS } from './constants'
|
||||
import { cleanError } from './log'
|
||||
import { assertHttpUrl } from './url'
|
||||
import {
|
||||
@@ -50,7 +51,7 @@ function probeFlat(url: string): Promise<FlatInfo> {
|
||||
execFile(
|
||||
getYtdlpPath(),
|
||||
['-J', '--flat-playlist', '--no-warnings', '--', url],
|
||||
{ windowsHide: true, maxBuffer: 256 * 1024 * 1024, timeout: 180_000 },
|
||||
{ windowsHide: true, maxBuffer: INDEX_MAX_BUFFER, timeout: INDEX_TIMEOUT_MS },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* IPC surface for the main process. Every `ipcMain.handle` (and the one
|
||||
* fire-and-forget `taskbarProgress` receiver) lives here, extracted from index.ts
|
||||
* so that module is just app lifecycle + window creation and this is the single
|
||||
* place the renderer's IPC contract is wired (L2).
|
||||
*
|
||||
* The handful of handlers that must touch the window (folder picker parenting,
|
||||
* theme-synced background, taskbar progress) get the current window through the
|
||||
* `getMainWindow` accessor rather than a captured reference, so they always act on
|
||||
* the live window. The small theme helpers they share are defined and exported
|
||||
* here too, since index's system-theme bridge uses them as well.
|
||||
*/
|
||||
|
||||
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
|
||||
import { existsSync } from 'fs'
|
||||
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 } 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 } from './settings'
|
||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
||||
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
||||
import { exportBackup, importBackup } from './backup'
|
||||
import {
|
||||
listSources,
|
||||
getSource,
|
||||
removeSource,
|
||||
listMediaItems,
|
||||
setMediaItemDownloaded,
|
||||
setSourceWatched
|
||||
} from './sources'
|
||||
import { indexSource } from './indexer'
|
||||
import { syncWatchedSources } from './sync'
|
||||
import { getScheduledSync, setScheduledSync } from './schedule'
|
||||
import { getActiveBadge, getErrorBadge } from './badge'
|
||||
import { openPoTokenWindow } from './poToken'
|
||||
|
||||
// --- Theme helpers (shared with index's system-theme bridge) ----------------
|
||||
|
||||
/** Resolve 'system' against the OS preference so callers always get a concrete color. */
|
||||
export 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.
|
||||
*/
|
||||
export function applyNativeTheme(theme: Settings['theme']): void {
|
||||
nativeTheme.themeSource = theme
|
||||
}
|
||||
|
||||
export function getSystemThemeInfo(): SystemThemeInfo {
|
||||
return {
|
||||
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
|
||||
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
|
||||
}
|
||||
}
|
||||
|
||||
// --- IPC registration -------------------------------------------------------
|
||||
|
||||
export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null): 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) => {
|
||||
const win = getMainWindow()
|
||||
if (!win || win.isDestroyed()) return
|
||||
if (p.mode === 'none') {
|
||||
win.setProgressBar(-1)
|
||||
win.setOverlayIcon(null, '')
|
||||
win.setTitle('AeroFetch')
|
||||
} else {
|
||||
win.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`
|
||||
win.setOverlayIcon(badge, label)
|
||||
win.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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
|
||||
/**
|
||||
* Filesystem path helpers for the main process. Kept separate from settings.ts
|
||||
* (the electron-store persistence layer) so path derivation and the settings
|
||||
* store don't share a module — they have different concerns and dependencies (L69).
|
||||
* All resolve app paths, so callers must run post-`app.ready`.
|
||||
*/
|
||||
|
||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
||||
export function getDownloadArchivePath(): string {
|
||||
return join(app.getPath('userData'), 'download-archive.txt')
|
||||
}
|
||||
|
||||
/**
|
||||
* The default per-kind download destination: video → Documents\Video,
|
||||
* audio → Documents\Audio. Used when the user hasn't set an explicit output
|
||||
* folder (Settings → Download folder), so downloads are sorted by type.
|
||||
*/
|
||||
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
|
||||
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Documents\Video and Documents\Audio folders up front (called once
|
||||
* at startup) so they exist the moment the app opens, not just after the first
|
||||
* download. Best-effort: yt-dlp also creates the output dir at download time, so
|
||||
* a failure here (read-only Documents, redirected folder) is non-fatal.
|
||||
*/
|
||||
export function ensureMediaDirs(): void {
|
||||
for (const kind of ['video', 'audio'] as const) {
|
||||
try {
|
||||
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
|
||||
} catch {
|
||||
/* non-fatal — the download path will be created on demand instead */
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
||||
import { PROBE_MAX_BUFFER, PROBE_TIMEOUT_MS } from './constants'
|
||||
import { fmtBytes } from '@shared/format'
|
||||
import { cleanError } from './log'
|
||||
import { assertHttpUrl } from './url'
|
||||
@@ -133,7 +134,7 @@ export function probeMedia(url: string): Promise<ProbeResult> {
|
||||
ytdlp,
|
||||
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||
['-J', '--flat-playlist', '--no-warnings', '--', target],
|
||||
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
|
||||
{ windowsHide: true, maxBuffer: PROBE_MAX_BUFFER, timeout: PROBE_TIMEOUT_MS },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
// execFile sets `killed` when it terminated the process on timeout.
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
import Store from 'electron-store'
|
||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||
import {
|
||||
@@ -43,36 +41,6 @@ export function applyLaunchAtStartup(enabled: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
||||
export function getDownloadArchivePath(): string {
|
||||
return join(app.getPath('userData'), 'download-archive.txt')
|
||||
}
|
||||
|
||||
/**
|
||||
* The default per-kind download destination: video → Documents\Video,
|
||||
* audio → Documents\Audio. Used when the user hasn't set an explicit output
|
||||
* folder (Settings → Download folder), so downloads are sorted by type.
|
||||
*/
|
||||
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
|
||||
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Documents\Video and Documents\Audio folders up front (called once
|
||||
* at startup) so they exist the moment the app opens, not just after the first
|
||||
* download. Best-effort: yt-dlp also creates the output dir at download time, so
|
||||
* a failure here (read-only Documents, redirected folder) is non-fatal.
|
||||
*/
|
||||
export function ensureMediaDirs(): void {
|
||||
for (const kind of ['video', 'audio'] as const) {
|
||||
try {
|
||||
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
|
||||
} catch {
|
||||
/* non-fatal — the download path will be created on demand instead */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
||||
// defaults for any missing/invalid field. Used both to migrate older settings
|
||||
// files (which predate downloadOptions) and to validate renderer writes.
|
||||
|
||||
+5
-5
@@ -16,11 +16,11 @@ import type { Source, MediaItem } from '@shared/ipc'
|
||||
import { isValidSource, isValidMediaItem } from './validation'
|
||||
import { mergeItemsPreservingState } from './indexerCore'
|
||||
import { createJsonStore } from './jsonStore'
|
||||
|
||||
// A generous global cap so a runaway index can't grow the file unbounded; large
|
||||
// enough for several big channels. When exceeded, the most-recently-written
|
||||
// source's items are kept (they're placed first by replaceMediaItems).
|
||||
const MAX_ITEMS = 20000
|
||||
// A generous global cap (MEDIA_ITEMS_MAX, see constants.ts) so a runaway index
|
||||
// can't grow the file unbounded; large enough for several big channels. When
|
||||
// exceeded, the most-recently-written source's items are kept (they're placed
|
||||
// first by replaceMediaItems).
|
||||
import { MEDIA_ITEMS_MAX as MAX_ITEMS } from './constants'
|
||||
|
||||
// Two cached, atomically-written stores (R1–R3 via the shared jsonStore). The
|
||||
// media-items store is the hot path: setMediaItemDownloaded used to re-read and
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@
|
||||
import { listSources, listMediaItems } from './sources'
|
||||
import { indexSource } from './indexer'
|
||||
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
|
||||
import { FEED_FETCH_TIMEOUT_MS } from './constants'
|
||||
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
|
||||
|
||||
/** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */
|
||||
@@ -16,7 +17,7 @@ async function fetchFeedIds(feedUrl: string): Promise<string[]> {
|
||||
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is
|
||||
// caught by the caller, which then falls back to a full yt-dlp re-index.
|
||||
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
|
||||
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) })
|
||||
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(FEED_FETCH_TIMEOUT_MS) })
|
||||
if (!res.ok) throw new Error(`feed responded ${res.status}`)
|
||||
return parseRssVideoIds(await res.text())
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { CommandTemplate } from '@shared/ipc'
|
||||
import { isTemplateLike } from './validation'
|
||||
import { createJsonStore } from './jsonStore'
|
||||
import { TEMPLATES_MAX } from './constants'
|
||||
|
||||
// Plain JSON in userData, same shape as history.ts. Atomic writes / corruption
|
||||
// backup / caching come from the shared jsonStore (R1–R3).
|
||||
const MAX_TEMPLATES = 100
|
||||
|
||||
// A persisted template entry must at least be an object carrying an id (see
|
||||
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
|
||||
// Drop anything that isn't, so a hand-edited templates.json can't inject
|
||||
// malformed entries. (audit S5)
|
||||
const store = createJsonStore('templates.json', isTemplateLike, MAX_TEMPLATES)
|
||||
const store = createJsonStore('templates.json', isTemplateLike, TEMPLATES_MAX)
|
||||
|
||||
export function listTemplates(): CommandTemplate[] {
|
||||
// Normalise each surviving entry through sanitize() so name/args are always
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ import { execFile } from 'child_process'
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
||||
import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants'
|
||||
import { getSettings, setSettings } from './settings'
|
||||
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
|
||||
import {
|
||||
@@ -47,7 +48,7 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||
execFile(
|
||||
ytdlpPath,
|
||||
['--version'],
|
||||
{ windowsHide: true, timeout: 15_000 },
|
||||
{ windowsHide: true, timeout: VERSION_TIMEOUT_MS },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
@@ -93,7 +94,7 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
|
||||
execFile(
|
||||
ytdlpPath,
|
||||
['--update-to', channel],
|
||||
{ windowsHide: true, timeout: 60_000 },
|
||||
{ windowsHide: true, timeout: YTDLP_UPDATE_TIMEOUT_MS },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = (err as { killed?: boolean }).killed
|
||||
|
||||
Reference in New Issue
Block a user