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:
2026-07-01 07:54:02 -04:00
parent 9e0064aab2
commit 36699531cf
38 changed files with 1004 additions and 458 deletions
+23 -8
View File
@@ -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',
+58
View File
@@ -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
View File
@@ -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
+2 -3
View File
@@ -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 (R1R3).
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
})
}
+40
View File
@@ -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
View File
@@ -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.
-32
View File
@@ -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
View File
@@ -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 (R1R3 via the shared jsonStore). The
// media-items store is the hot path: setMediaItemDownloaded used to re-read and
+2 -1
View File
@@ -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())
}
+2 -3
View File
@@ -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 (R1R3).
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
View File
@@ -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
+29 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
import { Sidebar, type TabValue } from './components/Sidebar'
import { Sidebar } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView'
import { LibraryView } from './components/LibraryView'
import { HistoryView } from './components/HistoryView'
@@ -11,6 +11,7 @@ import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion'
import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
import { useNav } from './store/nav'
import { useDownloads } from './store/downloads'
// Eagerly load the sources store for its startup side-effects (load persisted
// sources + the watched-source / scheduled `--sync` kickoff) and its cross-store
@@ -22,6 +23,10 @@ import { summarizeQueue } from './store/queueStats'
import { useResolvedDark } from './store/systemTheme'
import { logError } from './reportError'
// How often to check whether a scheduled ('saved' + due) download should promote
// to the queue. 15s is plenty for the minute-granularity times the picker offers.
const SCHEDULE_TICK_MS = 15_000
const useStyles = makeStyles({
provider: {
height: '100vh'
@@ -46,7 +51,8 @@ function App(): React.JSX.Element {
const updateSettings = useSettings((s) => s.update)
const showTerminal = useSettings((s) => s.customCommandEnabled)
const isDark = useResolvedDark()
const [tab, setTab] = useState<TabValue>('downloads')
const tab = useNav((s) => s.tab)
const setTab = useNav((s) => s.setTab)
const [paletteOpen, setPaletteOpen] = useState(false)
// Track the element that was focused before the palette opened so we can restore it on close.
const prePaletteRef = useRef<Element | null>(null)
@@ -92,6 +98,27 @@ function App(): React.JSX.Element {
return useDownloads.subscribe((st) => push(st.items))
}, [])
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
// Session-only: the queue isn't persisted, so a schedule only fires while the app
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
// Lives here (not at the downloads store's module load) so importing the store in
// tests/preview doesn't start a stray timer, and the interval is cleared cleanly
// on unmount (L1).
useEffect(() => {
const tick = setInterval(() => {
const st = useDownloads.getState()
const now = Date.now()
if (
st.items.some(
(i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
)
) {
st.promoteDueScheduled()
}
}, SCHEDULE_TICK_MS)
return () => clearInterval(tick)
}, [])
// Memoized so CommandPalette sees a stable array reference between renders --
// only rebuilds when the theme label needs to change (L32).
const paletteActions = useMemo<PaletteAction[]>(
+31 -6
View File
@@ -17,6 +17,7 @@ import {
MusicNote2Regular,
ErrorCircleRegular,
LinkRegular,
LibraryRegular,
DismissRegular,
CutRegular,
CalendarClockRegular,
@@ -62,7 +63,8 @@ export function DownloadBar(): React.JSX.Element {
selected,
allSelected,
suggestion,
suggestionSource
suggestionSource,
channelHint
} = bar
return (
@@ -80,20 +82,23 @@ export function DownloadBar(): React.JSX.Element {
onChange={(_, d) => bar.onUrlChange(d.value)}
onKeyDown={(e) => {
if (e.key !== 'Enter') return
if (info || probeError || playlist) bar.download()
else void bar.fetchFormats()
// Enter is "go": start the download (or add a fetched playlist). Preview
// formats stays an explicit, optional click on the button (UX2).
if (playlist) {
if (selected.size > 0) bar.addPlaylist()
} else bar.download()
}}
placeholder="Paste a video or playlist URL, then check…"
placeholder="Paste a video or playlist URL, then press Enter to download"
size="large"
contentBefore={<ArrowDownloadRegular />}
/>
<Hint label="Check URL" placement="bottom" align="end">
<Hint label="Preview available formats (optional)" placement="bottom" align="end">
<Button
size="large"
icon={probing ? <Spinner size="tiny" /> : <SearchRegular />}
onClick={bar.fetchFormats}
disabled={!url.trim() || probing}
aria-label="Check URL for formats or playlist"
aria-label="Preview available formats (optional)"
/>
</Hint>
<Hint label="Paste from clipboard" placement="bottom" align="end">
@@ -126,6 +131,26 @@ export function DownloadBar(): React.JSX.Element {
</div>
)}
{channelHint && (
<div className={styles.channelHint}>
<LibraryRegular />
<Caption1 className={styles.channelHintText}>
This looks like a channel or playlist. Add it in the Library to download many videos at
once.
</Caption1>
<Button size="small" appearance="primary" onClick={bar.openInLibrary}>
Open in Library
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={bar.dismissChannelHint}
aria-label="Dismiss Library suggestion"
/>
</div>
)}
{dup && (
<div className={styles.dupRow}>
<WarningRegular />
@@ -120,11 +120,17 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
</Field>
)}
{kind !== 'audio' && (
<Field label="Video container" hint="The file format used when video and audio are combined into one file.">
<Field
label="Video container"
hint="The file format used when video and audio are combined into one file."
>
<Select
aria-label="Video container"
value={value.videoContainer}
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
options={VIDEO_CONTAINERS.map((c) => ({
value: c,
label: VIDEO_CONTAINER_LABELS[c]
}))}
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
/>
</Field>
+21 -14
View File
@@ -14,7 +14,7 @@ import {
shorthands
} from '@fluentui/react-components'
import {
SearchRegular,
AddRegular,
ArrowSyncRegular,
DeleteRegular,
ArrowDownloadRegular,
@@ -31,6 +31,7 @@ import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { isPreview as PREVIEW } from '../isPreview'
import { useSources } from '../store/sources'
import { useSettings } from '../store/settings'
import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
import { logError } from '../reportError'
import { useDownloads, type DownloadStatus } from '../store/downloads'
@@ -269,6 +270,16 @@ export function LibraryView(): React.JSX.Element {
const [error, setError] = useState<string | null>(null)
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)
// A channel/playlist URL handed over from the Downloads bar (UX3): pre-fill the
// add field with it once, so the user lands here ready to add it.
const pendingLibraryUrl = useNav((s) => s.pendingLibraryUrl)
const consumeLibraryUrl = useNav((s) => s.consumeLibraryUrl)
useEffect(() => {
if (pendingLibraryUrl === null) return
const handed = consumeLibraryUrl()
if (handed) setUrl(handed)
}, [pendingLibraryUrl, consumeLibraryUrl])
// Reset any pending Remove confirmation when the expanded source changes so a
// stale confirm can't reappear after navigating between sources.
useEffect(() => {
@@ -376,7 +387,7 @@ export function LibraryView(): React.JSX.Element {
if (!u || indexing.active) return
const res = await indexSource(u)
if (res.ok) setUrl('')
else setError(res.error ?? 'Could not index that link.')
else setError(res.error ?? 'Could not add that link.')
}
function toggle(id: string, on: boolean): void {
@@ -469,11 +480,7 @@ export function LibraryView(): React.JSX.Element {
<span className={styles.groupTitle}>{row.title}</span>
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
</button>
<Button
size="small"
appearance="subtle"
onClick={() => toggleGroup(row.items, !allOn)}
>
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
{allOn ? 'None' : 'All'}
</Button>
<Button
@@ -519,7 +526,7 @@ export function LibraryView(): React.JSX.Element {
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader
title="Library"
description="Index a channel or playlist once, then download it into organized folders."
description="Add a channel or playlist once, then download its videos into organized folders."
/>
<div className={styles.addRow}>
@@ -529,7 +536,7 @@ export function LibraryView(): React.JSX.Element {
value={url}
onChange={(_, d) => setUrl(d.value)}
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
placeholder="Paste a channel or playlist URL…"
placeholder="Paste a channel or playlist URL to add it…"
size="large"
contentBefore={<LibraryRegular />}
disabled={indexing.active}
@@ -537,11 +544,11 @@ export function LibraryView(): React.JSX.Element {
<Button
size="large"
appearance="primary"
icon={indexing.active ? <Spinner size="tiny" /> : <SearchRegular />}
icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
onClick={onIndex}
disabled={!url.trim() || indexing.active}
>
Index
Add
</Button>
</div>
@@ -603,7 +610,7 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.progress}>
<Spinner size="tiny" />
<Text>
{indexing.message ?? 'Indexing…'}
{indexing.message ?? 'Adding…'}
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
</Text>
</div>
@@ -613,7 +620,7 @@ export function LibraryView(): React.JSX.Element {
{sources.length === 0 && !indexing.active ? (
<div className={styles.empty}>
<LibraryRegular fontSize={40} />
<Body1>No channels or playlists yet. Paste one above to index it.</Body1>
<Body1>No channels or playlists yet. Paste one above to add it.</Body1>
</div>
) : (
<div className={styles.list}>
@@ -692,7 +699,7 @@ export function LibraryView(): React.JSX.Element {
onClick={() => reindexSource(src.id)}
disabled={indexing.active}
>
Re-index
Refresh
</Button>
{confirmRemoveId === src.id ? (
<>
+69 -5
View File
@@ -6,6 +6,7 @@ import {
Field,
Button,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -14,7 +15,10 @@ import {
ClipboardPasteRegular,
HistoryRegular,
OptionsRegular,
RocketRegular
RocketRegular,
FolderRegular,
MusicNote2Regular,
VideoClipRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
@@ -55,7 +59,41 @@ const useStyles = makeStyles({
fontSize: '26px'
},
folderNote: {
color: tokens.colorNeutralForeground3
color: tokens.colorNeutralForeground3,
marginBottom: '8px'
},
folderRow: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 10px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
folderRowGap: {
marginTop: '8px'
},
folderIcon: {
fontSize: '18px',
flexShrink: 0,
color: tokens.colorCompoundBrandForeground1
},
folderCol: {
display: 'flex',
flexDirection: 'column',
minWidth: 0,
flexGrow: 1
},
folderLabel: {
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorNeutralForeground1
},
folderPath: {
color: tokens.colorNeutralForeground3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
tips: {
display: 'flex',
@@ -93,6 +131,9 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
export function Onboarding(): React.JSX.Element {
const styles = useStyles()
const update = useSettings((s) => s.update)
const videoDir = useSettings((s) => s.videoDir)
const audioDir = useSettings((s) => s.audioDir)
const chooseDir = useSettings((s) => s.chooseDir)
return (
<div className={styles.root}>
@@ -111,10 +152,33 @@ export function Onboarding(): React.JSX.Element {
<Field label="Where downloads go">
<Caption1 className={styles.folderNote}>
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
<strong>Documents\Audio</strong>. You can point each to a different folder any time in
Settings.
Videos and audio save to your Documents folders by default. Pick a different folder now,
or change it any time in Settings.
</Caption1>
<div className={styles.folderRow}>
<VideoClipRegular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Video</Caption1>
<Caption1 className={styles.folderPath} title={videoDir || 'Documents\\Video'}>
{videoDir || 'Documents\\Video'}
</Caption1>
</div>
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
Choose
</Button>
</div>
<div className={mergeClasses(styles.folderRow, styles.folderRowGap)}>
<MusicNote2Regular className={styles.folderIcon} />
<div className={styles.folderCol}>
<Caption1 className={styles.folderLabel}>Audio</Caption1>
<Caption1 className={styles.folderPath} title={audioDir || 'Documents\\Audio'}>
{audioDir || 'Documents\\Audio'}
</Caption1>
</div>
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
Choose
</Button>
</div>
</Field>
<div className={styles.tips}>
+4 -1
View File
@@ -51,7 +51,10 @@ export function SettingsView(): React.JSX.Element {
return (
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
<div data-settings-search>
<ScreenHeader title="Settings" description="Folders, formats, network, cookies, and more." />
<ScreenHeader
title="Settings"
description="Folders, formats, network, cookies, and more."
/>
</div>
<div data-settings-search>
<Input
@@ -36,6 +36,21 @@ export const useDownloadBarStyles = makeStyles({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
// The channel/playlist nudge toward the Library (UX3): same brand-tinted banner
// as the link suggestion, but its message wraps instead of truncating.
channelHint: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 8px 8px 12px',
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
channelHintText: {
flexGrow: 1,
minWidth: 0
},
url: {
flexGrow: 1
},
@@ -12,9 +12,11 @@ import { useDownloads, type MediaKind } from '../../store/downloads'
import { QUALITY_OPTIONS } from '../../qualityOptions'
import { sameVideo } from '../../store/queueStats'
import { useSettings } from '../../store/settings'
import { useNav } from '../../store/nav'
import {
useClipboardLink,
looksLikeUrl,
looksLikeChannelOrPlaylist,
firstUrlInText,
type SuggestionSource
} from '../../useClipboardLink'
@@ -77,6 +79,10 @@ export interface DownloadBarController {
suggestion: string | null
suggestionSource: SuggestionSource
dismissSuggestion: () => void
// channel/playlist nudge → Library (UX3)
channelHint: boolean
openInLibrary: () => void
dismissChannelHint: () => void
// handlers
onDragOver: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
@@ -108,6 +114,7 @@ export interface DownloadBarController {
export function useDownloadBar(): DownloadBarController {
const addFromUrl = useDownloads((s) => s.addFromUrl)
const addMany = useDownloads((s) => s.addMany)
const openLibraryWith = useNav((s) => s.openLibraryWith)
const settingsLoaded = useSettings((s) => s.loaded)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
@@ -128,6 +135,10 @@ export function useDownloadBar(): DownloadBarController {
// Drag-and-drop: highlight while a link / .url file hovers the card.
const [dragActive, setDragActive] = useState(false)
// Channel/playlist nudge: a whole channel/playlist belongs in the Library, not
// the one-off queue. Once dismissed for the current URL, stay quiet (UX3).
const [channelHintDismissed, setChannelHintDismissed] = useState(false)
// Duplicate guard: warn before enqueuing a URL already in the active queue.
const [dup, setDup] = useState<string | null>(null)
const confirmDup = useRef(false)
@@ -213,6 +224,16 @@ export function useDownloadBar(): DownloadBarController {
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
: undefined
// Show the "add this in the Library" nudge for a clear channel/playlist URL,
// but only while the bar is still idle for it (nothing probed/resolved yet) and
// the user hasn't waved it away (UX3).
const channelHint =
looksLikeChannelOrPlaylist(url) &&
!channelHintDismissed &&
info === null &&
playlist === null &&
probeError === null
function clearProbe(): void {
setInfo(null)
setProbeError(null)
@@ -260,6 +281,8 @@ export function useDownloadBar(): DownloadBarController {
if (dup) setDup(null)
setCommandPreview(null)
confirmDup.current = false
// A new URL gets a fresh chance to nudge toward the Library.
setChannelHintDismissed(false)
}
function onKindChange(next: MediaKind): void {
@@ -324,6 +347,22 @@ export function useDownloadBar(): DownloadBarController {
}
}
// Hand the current channel/playlist URL to the Library tab, which pre-fills its
// add field with it, and clear the bar (the URL now lives over there) (UX3).
function openInLibrary(): void {
const trimmed = url.trim()
if (!trimmed) return
openLibraryWith(trimmed)
setUrl('')
// Clear the URL-specific command preview too, so no stale command lingers over
// the now-empty bar (the same invariant onUrlChange keeps).
setCommandPreview(null)
clearProbe()
}
function dismissChannelHint(): void {
setChannelHintDismissed(true)
}
// Selection helpers for the playlist panel.
const allSelected = playlist !== null && selected.size === playlist.entries.length
function toggleEntry(index: number, on: boolean): void {
@@ -497,6 +536,10 @@ export function useDownloadBar(): DownloadBarController {
suggestion,
suggestionSource,
dismissSuggestion,
// channel/playlist nudge → Library (UX3)
channelHint,
openInLibrary,
dismissChannelHint,
// handlers
onDragOver,
onDrop,
@@ -42,7 +42,10 @@ export function AppearanceCard(): React.JSX.Element {
<button
key={opt.value}
type="button"
className={mergeClasses(styles.swatch, accentColor === opt.value && styles.swatchActive)}
className={mergeClasses(
styles.swatch,
accentColor === opt.value && styles.swatchActive
)}
style={{ backgroundColor: opt.swatch }}
onClick={() => update({ accentColor: opt.value as AccentColor })}
aria-label={`${opt.label}${accentColor === opt.value ? ' (selected)' : ''}`}
@@ -21,8 +21,8 @@ export function CustomCommandsCard(): React.JSX.Element {
</div>
<Caption1 className={styles.hint}>
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other
option, so a template flag can override a setting above it.
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other option,
so a template flag can override a setting above it.
</Caption1>
<Field
@@ -36,7 +36,11 @@ export function DiagnosticsCard(): React.JSX.Element {
</Caption1>
<div className={styles.folderRow}>
<Button icon={<CopyRegular />} onClick={copyErrorReport} disabled={errorEntries.length === 0}>
<Button
icon={<CopyRegular />}
onClick={copyErrorReport}
disabled={errorEntries.length === 0}
>
Copy full report
</Button>
{confirmClearLog ? (
@@ -77,7 +81,9 @@ export function DiagnosticsCard(): React.JSX.Element {
<div key={e.id} className={styles.errorRow}>
<div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<Caption1 className={styles.hint}>{new Date(e.occurredAt).toLocaleString()}</Caption1>
<Caption1 className={styles.hint}>
{new Date(e.occurredAt).toLocaleString()}
</Caption1>
</div>
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
</div>
@@ -1,4 +1,12 @@
import { Field, Input, SpinButton, Switch, Button, Card, Subtitle2 } from '@fluentui/react-components'
import {
Field,
Input,
SpinButton,
Switch,
Button,
Card,
Subtitle2
} from '@fluentui/react-components'
import { FolderRegular, ArrowDownloadRegular } from '@fluentui/react-icons'
import { type MediaKind } from '@shared/ipc'
import { useSettings } from '../../store/settings'
@@ -20,7 +20,10 @@ export function FilenamesCard(): React.JSX.Element {
label="Filename template"
hint="Controls how saved files are named. Use %(title)s for the video title, %(ext)s for the extension, and similar tokens."
>
<Input value={filenameTemplate} onChange={(_, d) => update({ filenameTemplate: d.value })} />
<Input
value={filenameTemplate}
onChange={(_, d) => update({ filenameTemplate: d.value })}
/>
</Field>
<Caption1 className={styles.hint}>
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
@@ -39,7 +39,11 @@ export function NetworkCard(): React.JSX.Element {
label="Rate limit"
hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited."
>
<Input value={rateLimit} placeholder="2M" onChange={(_, d) => update({ rateLimit: d.value })} />
<Input
value={rateLimit}
placeholder="2M"
onChange={(_, d) => update({ rateLimit: d.value })}
/>
</Field>
<Field
+28
View File
@@ -62,6 +62,34 @@ export function looksLikeSingleVideo(text: string): boolean {
return false
}
/**
* True for URLs that point at a whole channel or playlist rather than a single
* video the shapes that belong in the Library (index once, download in bulk),
* not the one-off Downloads bar (UX3). Conservative on purpose: it only flags the
* clear YouTube channel and dedicated-playlist shapes, so a plain `/watch?v=…`
* video (even one carrying a `list=` context) is never flagged and keeps working
* in the Downloads bar; a non-YouTube link is never flagged.
*/
export function looksLikeChannelOrPlaylist(text: string): boolean {
let u: URL
try {
u = new URL(text.trim())
} catch {
return false
}
const host = u.hostname.replace(/^www\./, '').toLowerCase()
const isYouTube =
host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com'
if (!isYouTube) return false
// A dedicated playlist page (not a /watch video that merely carries a list).
if (u.pathname === '/playlist' && u.searchParams.has('list')) return true
// Channel shapes: /channel/UC…, /c/Name, /user/Name, /@handle — with an optional
// /videos, /streams, /shorts, /playlists tab suffix.
if (/^\/(channel|c|user)\/[^/]+/i.test(u.pathname)) return true
if (/^\/@[^/]+/.test(u.pathname)) return true
return false
}
/**
* Return the first http(s) URL from a multi-line blob of text.
* Lines are trimmed; '#' comment lines are skipped.
+3 -12
View File
@@ -402,18 +402,9 @@ export const useDownloads = create<DownloadState>((set, get) => {
// no direct import from sources, so the two stores stay acyclic.
on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries))
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
// Session-only: the queue isn't persisted, so a schedule only fires while the app
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
setInterval(() => {
const st = useDownloads.getState()
const now = Date.now()
if (
st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)
) {
st.promoteDueScheduled()
}
}, 15_000)
// The scheduled-download promoter tick lives in App (a useEffect), not here, so
// importing this store in tests/preview doesn't start a stray never-cleared timer
// on module load (L1). See SCHEDULE_TICK_MS / App.tsx.
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
if (!PREVIEW) {
+32
View File
@@ -0,0 +1,32 @@
import { create } from 'zustand'
import type { TabValue } from '../components/Sidebar'
/**
* The active screen, owned in a store so any component can navigate without
* threading an `onTabChange` prop through the tree. It also carries a one-shot
* URL handoff: the Downloads bar detects a channel/playlist link (which belongs
* in the Library, not the one-off queue) and hands it here; the Library consumes
* it once to pre-fill its add field (UX3).
*/
interface NavState {
tab: TabValue
setTab: (tab: TabValue) => void
/** A channel/playlist URL handed from the Downloads bar to the Library. */
pendingLibraryUrl: string | null
/** Switch to the Library tab and stage `url` for its add field. */
openLibraryWith: (url: string) => void
/** Read and clear the staged Library URL (returns null if none is pending). */
consumeLibraryUrl: () => string | null
}
export const useNav = create<NavState>((set, get) => ({
tab: 'downloads',
setTab: (tab) => set({ tab }),
pendingLibraryUrl: null,
openLibraryWith: (url) => set({ tab: 'library', pendingLibraryUrl: url }),
consumeLibraryUrl: () => {
const url = get().pendingLibraryUrl
if (url !== null) set({ pendingLibraryUrl: null })
return url
}
}))
+1 -1
View File
@@ -149,7 +149,7 @@ export const useSources = create<SourcesState>((set, get) => ({
reindexSource: async (id) => {
const src = get().sources.find((s) => s.id === id)
set({ indexing: { active: true, url: src?.url, message: 'Re-indexing…' } })
set({ indexing: { active: true, url: src?.url, message: 'Refreshing…' } })
if (PREVIEW) {
await new Promise((r) => setTimeout(r, 700))
set({ indexing: { active: false } })
+6 -1
View File
@@ -3,7 +3,12 @@ import { useSettings } from './store/settings'
// Pure URL helpers extracted to a standalone module so they can be tested
// without pulling in the Zustand store (L40). Re-exported here for existing
// consumers (DownloadBar, LibraryView) without an import-path change.
export { looksLikeUrl, looksLikeSingleVideo, firstUrlInText } from './lib/urlHelpers'
export {
looksLikeUrl,
looksLikeSingleVideo,
looksLikeChannelOrPlaylist,
firstUrlInText
} from './lib/urlHelpers'
import { looksLikeUrl } from './lib/urlHelpers'
/** Where an offered link came from — drives the banner's wording. */