feat(audit): Batch 25 — project reorg (CC12), leading DI args (CC7), CC10 by-design

CC12: renderer views/ (5 screens + Onboarding + settings cards) +
lib/ (theme/thumb/datetime/id/qualityOptions/useClipboardLink/queueStats);
main core/ (buildArgs/validation/indexerCore/ytdlpPolicy). git mv preserved
history; all src+test imports updated. Build emits view chunks from views/,
344 tests + typecheck green, live probe rendered all screens.
CC7: wc/onProgress is the leading arg everywhere — downloadAppUpdate(wc,url),
indexSource(onProgress,url,signal), indexSourceCancelable(onProgress,url).
CC10: closed by-design — jsonStore backs all records; settings stay in
electron-store for DPAPI secret encryption (a deliberate two-store split).
Also closes the UI24/W4 context-menu cross-reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:37:28 -04:00
parent f45acb645b
commit 9134e7d216
70 changed files with 160 additions and 151 deletions
+27 -18
View File
@@ -1845,7 +1845,9 @@ DPI handled by Chromium). The deviations:
- [x] **W9 — Focus isn't restored or advanced.** Closing the command palette doesn't return focus to the
trigger; finishing onboarding and switching tabs don't move focus into the new content. **Standard:**
restore focus on overlay close; move focus to the activated panel. *(extends UI28UI29)*
- [ ] **(ref) Context menus absent app-wide** — UI24; the text-field case is W4.
- [x] **(ref) Context menus absent app-wide** — UI24; the text-field case is W4. *Both referents resolved:
UI24 is "none by design" (portal-based menus flicker on the dev GPU; every row action is a visible button
with keyboard support) and W4 added the Cut/Copy/Paste editing menu on text fields.*
### System tray, taskbar & icons
@@ -1998,16 +2000,17 @@ split — but that style is unenforced and several "do the same thing two ways"
broader "one `Result<T>` across every boundary" holds already for the service/IPC layer (they return
`{ ok, error }`); collapsing the remaining `null`/`[]`/bare-string internal signals into it is a low-value
sweep left to the incremental track — the specific `cleanError` inconsistency this item names is closed.*
- [ ] **CC7 — Dependency-injection styles.** Pure modules take deps as params (good — `binDir`,
- [x] **CC7 — Dependency-injection styles.** Pure modules take deps as params (good — `binDir`,
`now`); the impure shell uses module singletons (`getSettings()`, `getYtdlpPath()`, lazy `getStore()`);
progress is callback-injected; the `WebContents` sender is a param in some handlers and a closure in
others. **Standard:** keep pure-core param injection; in the shell, pass `wc`/`onProgress` consistently
as the leading argument and keep singleton access for config/binaries.
*Deferred (low-value restyle): the substantive half already holds — the pure core
(`buildArgs`/`validation`/`indexerCore`/`ytdlpPolicy`) takes deps as params and the shell uses singletons
for config/binaries, which is the recommended split. What's left is purely cosmetic argument-ordering
(`wc`/`onProgress` position), not worth the churn/risk across every handler unattended; recorded as the
convention for new handlers.*
*Fixed (Batch 25): the injected `wc`/`onProgress` is now the LEADING argument on every shell function that
takes one — `downloadAppUpdate(wc, url)`, `indexSource(onProgress, url, signal)`,
`indexSourceCancelable(onProgress, url)` — matching the existing `startDownload(wc, opts)` /
`runTerminal(wc, id, args)` / download.ts `send(wc, ev)`/`notify(wc, …)`. Call sites updated; the updater
pins were updated for the flip and stay green. Singleton access for config/binaries is kept as the
recommended split.*
- [x] **CC8 — No logging strategy.** Effectively no diagnostics — a single stray `console.error` in
preload, and ~29 swallowed catches (M29); `errorlog.ts` is domain data, not logging. **Standard:** one
small leveled logger (e.g. `electron-log`) written to userData, called at every catch; keep `errorlog.ts` for user-facing download failures only.
@@ -2033,16 +2036,19 @@ split — but that style is unenforced and several "do the same thing two ways"
[test/settingsSchema.test.ts](test/settingsSchema.test.ts); the whole suite passes unchanged. The jsonStore
row guards deliberately stay type-guard predicates (hot read path filtering untrusted disk rows — no
coercion wanted), documented in the schema header.*
- [ ] **CC10 — Mixed serialization/persistence.** `electron-store` (settings) **and** hand-rolled
- [x] **CC10 — Mixed serialization/persistence.** `electron-store` (settings) **and** hand-rolled
pretty-JSON files (history/errorlog/templates/sources/media-items, M1) for the same job, plus
yt-dlp-mandated formats (Netscape cookies, plaintext archive) and base64 `enc:v1:` secrets.
**Standard:** one `jsonStore<T>()` abstraction for the app's own records; pick **either** electron-store
**or** the JSON stores for everything, not both; leave the yt-dlp-format files alone.
*Partly closed / deferred: the `jsonStore<T>()` abstraction now exists (`createJsonStore`, M4/R1) and backs
every app record (history/errorlog/templates/sources/media-items/queue), so the "hand-rolled per file"
divergence is gone. The remaining electron-store↔jsonStore split is a **deliberate** keep: settings live in
electron-store for its DPAPI-encrypted secret fields, records in jsonStore. Fully migrating settings off
electron-store is a 1.x call, not forced here.*
*Resolved as deliberate-by-design (Batch 25): the `jsonStore<T>()` abstraction (`createJsonStore`, M4/R1)
already backs every app record (history/errorlog/templates/sources/media-items/queue), so the
"hand-rolled per file" divergence is gone. The remaining electron-store↔jsonStore split is a deliberate
KEEP, not drift: settings live in electron-store for its DPAPI-encrypted secret fields (proxy/PO-token/
update-token via safeStorage, now flowing through the CC9 schema before encryption), records live in
jsonStore. Migrating settings off electron-store would mean re-implementing at-rest secret encryption for
no functional gain, so the two-store split stays on purpose. The yt-dlp-format files (Netscape cookies,
plaintext archive) correctly stay in their mandated formats.*
- [x] **CC11 — Configuration is scattered.** `electron-store` + `localStorage` (sidebar, M19) + env vars
(`PORTABLE_EXECUTABLE_DIR`, `CSC_LINK`, `AEROFETCH_REAL_DOWNLOAD`) + hardcoded module consts (update
host/owner/repo, timeouts, caps, `09:00`, `ARIA2C_ARGS`, L10). **Standard:** a `config.ts` for build/host
@@ -2051,14 +2057,17 @@ split — but that style is unenforced and several "do the same thing two ways"
now live in [config.ts](src/main/config.ts), imported by the updater — deploy identity in one file,
runtime tunables in constants.ts (L10), user prefs in settings (localStorage was folded in by M19). The
env vars are genuinely environmental (portable mode, CI signing, integration-test opt-in) and stay env vars.*
- [ ] **CC12 — Project organization.** Renderer `components/` mixes screens (views) with reusable widgets;
- [x] **CC12 — Project organization.** Renderer `components/` mixes screens (views) with reusable widgets;
helpers (`theme`/`thumb`/`useClipboardLink`) sit at src root; the **pure** `queueStats` lives in `store/`;
main mixes pure (`buildArgs`/`validation`/`indexerCore`/`ytdlpPolicy`) and impure modules in one flat dir.
**Standard:** renderer `views/` + `components/` + `lib/`; main `core/` (pure) + services; move `queueStats` to `lib`.
*Deferred to 1.x: a file-tree reorg is broad import churn across nearly every module for no behavior change —
exactly the kind of sweeping move that wants its own reviewed PR, not an unattended batch. The `lib/`
convention is already established (both sides have one and new pure utils land there); the wholesale
`views/`+`core/` split is the 1.x task. Standard recorded.*
*Fixed (Batch 25): the reorg landed. Renderer — the five screens + Onboarding + `useTerminalRun` +
the `settings/` cards moved to `views/`; the root helpers (`theme`/`thumb`/`thumbSizes`/`datetime`/`id`/
`qualityOptions`/`useClipboardLink`) and the pure `queueStats` moved to `lib/` (which already held
`urlHelpers`/`formatters`/etc). Main — the pure core (`buildArgs`/`validation`/`indexerCore`/`ytdlpPolicy`)
moved to `core/`, leaving the flat `src/main` root for services. `git mv` preserved history; every import
(src + test) updated. Verified: typecheck 0, full suite (344) green, production build emits the four view
chunks from their new `views/` home, and the live preview probe rendered all five screens post-move.*
- [x] **CC13 — View/state boundary (the project's "MVVM").** It's React+Zustand, but the container/
presentational split is inconsistent: orchestration lives in stores for downloads/sources yet inside the
component for DownloadBar, and SettingsView calls `window.api` directly (L93, UX1). **Standard:** stores/
+1 -1
View File
@@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings, SECRET_KEYS } from './settings'
import { migrateSettingsKeys } from './settingsMigration'
import { listTemplates, replaceTemplates } from './templates'
import { isTemplateLike } from './validation'
import { isTemplateLike } from './core/validation'
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
interface BackupFile {
+2 -2
View File
@@ -23,7 +23,7 @@ import { ensureManagedYtdlp } from './ytdlp'
import { materializeCookies, hasStoredCookies } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { isSafeOutputDir } from './validation'
import { isSafeOutputDir } from './core/validation'
import {
buildArgs,
selectExtraArgs,
@@ -32,7 +32,7 @@ import {
PROGRESS_MARKER,
FILEPATH_MARKER,
DEST_MARKER
} from './buildArgs'
} from './core/buildArgs'
import { cleanError } from './log'
import { addErrorLog } from './errorlog'
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ErrorLogEntry } from '@shared/ipc'
import { isValidErrorLogEntry } from './validation'
import { isValidErrorLogEntry } from './core/validation'
import { createJsonStore } from './jsonStore'
import { ERRORLOG_MAX_ENTRIES } from './constants'
+1 -1
View File
@@ -1,5 +1,5 @@
import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
import { isValidHistoryEntry } from './validation'
import { isValidHistoryEntry } from './core/validation'
import { createJsonStore } from './jsonStore'
// Plain JSON in userData (portable build redirects userData next to the exe).
+5 -5
View File
@@ -24,7 +24,7 @@ import {
stableSourceId,
type RawEntry,
type NamedPlaylist
} from './indexerCore'
} from './core/indexerCore'
import { getSource, upsertSource, mergeMediaItems } from './sources'
import type { IndexProgress, IndexSourceResult, Source, SourceKind } from '@shared/ipc'
@@ -89,13 +89,13 @@ export function cancelIndexing(): void {
* The IPC layer calls this; sync.ts calls the raw indexSource (not cancelable).
*/
export async function indexSourceCancelable(
url: string,
onProgress: (p: IndexProgress) => void
onProgress: (p: IndexProgress) => void,
url: string
): Promise<IndexSourceResult> {
const controller = new AbortController()
activeIndexAborts.add(controller)
try {
return await indexSource(url, onProgress, controller.signal)
return await indexSource(onProgress, url, controller.signal)
} finally {
activeIndexAborts.delete(controller)
}
@@ -107,8 +107,8 @@ export async function indexSourceCancelable(
* status line — it's best-effort and never affects the result.
*/
export async function indexSource(
url: string,
onProgress: (p: IndexProgress) => void,
url: string,
signal?: AbortSignal
): Promise<IndexSourceResult> {
// Returned whenever a Cancel arrives mid-walk. It deliberately does NOT push a
+5 -5
View File
@@ -101,7 +101,7 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
downloadAppUpdate(url, e.sender)
downloadAppUpdate(e.sender, url)
)
ipcMain.handle(IpcChannels.appUpdateCancel, () => cancelAppUpdate())
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
@@ -253,16 +253,16 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
// Indexing pushes live progress to the requesting renderer over `indexProgress`
// and resolves with the final result.
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
indexSourceCancelable(url, (p) => {
indexSourceCancelable((p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
}, url)
)
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
const src = getSource(id)
if (!src) return { ok: false, error: 'Source not found.' }
return indexSourceCancelable(src.url, (p) => {
return indexSourceCancelable((p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
}, src.url)
})
// Abort the in-flight index/reindex walk (kills the current probe child). (B2)
ipcMain.handle(IpcChannels.sourceIndexCancel, () => cancelIndexing())
+1 -1
View File
@@ -5,7 +5,7 @@ import { PROBE_MAX_BUFFER, PROBE_TIMEOUT_MS } from './constants'
import { fmtBytes } from '@shared/format'
import { cleanError } from './log'
import { assertHttpUrl } from './url'
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore'
import { entryUrl, fmtDuration, type RawEntry } from './core/indexerCore'
import {
BEST_FORMAT_ID,
type ProbeResult,
+1 -1
View File
@@ -25,7 +25,7 @@ import {
isValidSyncTime,
type Settings
} from '@shared/ipc'
import { isSafeOutputDir, isSafeFilenameTemplate } from './validation'
import { isSafeOutputDir, isSafeFilenameTemplate } from './core/validation'
import { sanitizeOptions } from './settingsOptions'
/** Coerce to a finite number, round, and clamp into [min, max] (maxConcurrent, aria2c). */
+2 -2
View File
@@ -13,8 +13,8 @@
*/
import type { Source, MediaItem } from '@shared/ipc'
import { isValidSource, isValidMediaItem } from './validation'
import { mergeItemsPreservingState } from './indexerCore'
import { isValidSource, isValidMediaItem } from './core/validation'
import { mergeItemsPreservingState } from './core/indexerCore'
import { createJsonStore } from './jsonStore'
// 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
+2 -2
View File
@@ -7,7 +7,7 @@
import { listSources, listMediaItems } from './sources'
import { indexSource } from './indexer'
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
import { parseRssVideoIds, isYouTubeFeedUrl } from './core/indexerCore'
import { FEED_FETCH_TIMEOUT_MS } from './constants'
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
@@ -41,7 +41,7 @@ export async function syncWatchedSources(
if (recent && recent.length > 0 && recent.every((id) => known.has(id))) continue
}
const before = new Set(listMediaItems(src.id).map((m) => m.videoId))
const res = await indexSource(src.url, onProgress)
const res = await indexSource(onProgress, src.url)
if (res.ok) {
for (const it of listMediaItems(src.id)) {
if (!before.has(it.videoId)) newItems.push(it)
+1 -1
View File
@@ -1,5 +1,5 @@
import type { CommandTemplate } from '@shared/ipc'
import { isTemplateLike } from './validation'
import { isTemplateLike } from './core/validation'
import { createJsonStore } from './jsonStore'
import { TEMPLATES_MAX } from './constants'
+1 -1
View File
@@ -14,7 +14,7 @@ import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
import { getSettings } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock'
import { parseExtraArgs } from './buildArgs'
import { parseExtraArgs } from './core/buildArgs'
import { createLineBuffer } from './lib/lineBuffer'
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
+1 -1
View File
@@ -411,7 +411,7 @@ function streamInstallerToFile(opts: {
}
/** Stream the installer to a temp file, pushing progress events to the renderer. */
export async function downloadAppUpdate(url: string, wc: WebContents): Promise<AppUpdateDownload> {
export async function downloadAppUpdate(wc: WebContents, url: string): Promise<AppUpdateDownload> {
if (!isTrustedDownloadUrl(url)) {
return { ok: false, error: 'Refused to download from an untrusted location.' }
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { execFileAsync } from './lib/exec'
import { cleanError } from './log'
import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants'
import { getSettings, setSettings } from './settings'
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
import { shouldAutoCheckYtdlp } from './core/ytdlpPolicy'
import { beginYtdlpUpdate, endYtdlpUpdate, liveSpawnCount } from './ytdlpLock'
import {
isYtdlpUpdateChannel,
+8 -8
View File
@@ -1,12 +1,12 @@
import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react'
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
import { Sidebar } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView'
import { Onboarding } from './components/Onboarding'
import { DownloadsView } from './views/DownloadsView'
import { Onboarding } from './views/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion'
import { Toaster } from './components/ui/Toaster'
import { getTheme, pageBackground } from './theme'
import { getTheme, pageBackground } from './lib/theme'
import { useSettings } from './store/settings'
import { useNav } from './store/nav'
import { useDownloads } from './store/downloads'
@@ -16,7 +16,7 @@ import { useDownloads } from './store/downloads'
// was guaranteed by downloads.ts importing it; now that the cycle is broken, App
// owns the eager load so it survives the views becoming lazy-loaded (PERF8).
import './store/sources'
import { queueSummaryOf } from './store/queueStats'
import { queueSummaryOf } from './lib/queueStats'
import { useResolvedDark } from './store/systemTheme'
import { logError } from './reportError'
@@ -26,16 +26,16 @@ import { logError } from './reportError'
// views use are imported eagerly above (`useDownloads` + `./store/sources`, and
// downloads → history), so lazy-loading the *view* never delays store startup.
const LibraryView = lazy(() =>
import('./components/LibraryView').then((m) => ({ default: m.LibraryView }))
import('./views/LibraryView').then((m) => ({ default: m.LibraryView }))
)
const HistoryView = lazy(() =>
import('./components/HistoryView').then((m) => ({ default: m.HistoryView }))
import('./views/HistoryView').then((m) => ({ default: m.HistoryView }))
)
const TerminalView = lazy(() =>
import('./components/TerminalView').then((m) => ({ default: m.TerminalView }))
import('./views/TerminalView').then((m) => ({ default: m.TerminalView }))
)
const SettingsView = lazy(() =>
import('./components/SettingsView').then((m) => ({ default: m.SettingsView }))
import('./views/SettingsView').then((m) => ({ default: m.SettingsView }))
)
// How often to check whether a scheduled ('saved' + due) download should promote
+1 -1
View File
@@ -27,7 +27,7 @@ import {
import { useEffect, useRef } from 'react'
import { type MediaKind } from '../store/downloads'
import { useNav } from '../store/nav'
import { QUALITY_OPTIONS } from '../qualityOptions'
import { QUALITY_OPTIONS } from '../lib/qualityOptions'
import { Select } from './Select'
import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl'
+1 -1
View File
@@ -3,7 +3,7 @@ import { makeStyles, mergeClasses } from '@fluentui/react-components'
import { VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
import type { MediaKind } from '@shared/ipc'
import { useResolvedDark } from '../store/systemTheme'
import { thumbColors } from '../theme'
import { thumbColors } from '../lib/theme'
const useStyles = makeStyles({
box: {
+3 -3
View File
@@ -27,8 +27,8 @@ import {
} from '@fluentui/react-icons'
import { useShallow } from 'zustand/react/shallow'
import { useDownloads, type DownloadItem } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { fmtSchedule } from '../datetime'
import { thumbUrl } from '../lib/thumb'
import { fmtSchedule } from '../lib/datetime'
import { fmtSpeed, fmtEta } from '@shared/format'
import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
@@ -36,7 +36,7 @@ import { StatusChip } from './ui/StatusChip'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
import { THUMB_MD } from '../thumbSizes'
import { THUMB_MD } from '../lib/thumbSizes'
const useStyles = makeStyles({
root: {
@@ -19,7 +19,7 @@ import {
} from '@fluentui/react-icons'
import type { CommandTemplate } from '@shared/ipc'
import { useTemplates } from '../store/templates'
import { newId } from '../id'
import { newId } from '../lib/id'
import { Hint } from './Hint'
import { EmptyState } from './ui/EmptyState'
import { SPACE } from './ui/tokens'
@@ -1,5 +1,5 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
import { THUMB_LG } from '../../thumbSizes'
import { THUMB_LG } from '../../lib/thumbSizes'
import { SPACE, RADIUS, ELEVATION } from '../ui/tokens'
export const useDownloadBarStyles = makeStyles({
@@ -10,8 +10,8 @@ import {
} from '@shared/ipc'
import { useDownloads, type MediaKind } from '../../store/downloads'
import { useHistory } from '../../store/history'
import { QUALITY_OPTIONS } from '../../qualityOptions'
import { sameVideo } from '../../store/queueStats'
import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
import { sameVideo } from '../../lib/queueStats'
import { useSettings } from '../../store/settings'
import { useNav } from '../../store/nav'
import {
@@ -20,7 +20,7 @@ import {
looksLikeChannelOrPlaylist,
firstUrlInText,
type SuggestionSource
} from '../../useClipboardLink'
} from '../../lib/useClipboardLink'
import { logError } from '../../reportError'
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
@@ -4,7 +4,7 @@
* / window dependency (only a type-only import of DownloadItem) so it can be
* unit-tested without constructing the Zustand store or its preview ticker.
*/
import type { DownloadItem } from './downloads'
import type { DownloadItem } from '../store/downloads'
import { youtubeId } from '../lib/urlHelpers'
import { fmtSpeed, fmtEta } from '@shared/format'
@@ -4,7 +4,7 @@
* video id with no extra network probe. Anything non-YouTube without a probed
* thumbnail returns undefined, and the UI falls back to a kind icon.
*/
import { youtubeId } from './lib/urlHelpers'
import { youtubeId } from './urlHelpers'
/**
* Resolve a preview thumbnail URL for a media item. Prefers an explicit thumbnail
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSettings } from './store/settings'
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.
@@ -8,8 +8,8 @@ export {
looksLikeSingleVideo,
looksLikeChannelOrPlaylist,
firstUrlInText
} from './lib/urlHelpers'
import { looksLikeUrl } from './lib/urlHelpers'
} from './urlHelpers'
import { looksLikeUrl } from './urlHelpers'
/** Where an offered link came from — drives the banner's wording. */
export type SuggestionSource = 'clipboard' | 'external'
+1 -1
View File
@@ -1,5 +1,5 @@
import { type DownloadMeta, type MediaKind } from '@shared/ipc'
import { newId } from '../id'
import { newId } from '../lib/id'
import { youtubeId } from '../lib/urlHelpers'
import { type AddOptions, type DownloadItem } from './downloadTypes'
+1 -1
View File
@@ -1,5 +1,5 @@
import { isPreview as PREVIEW } from '../isPreview'
import { newId } from '../id'
import { newId } from '../lib/id'
import { type DownloadItem } from './downloadTypes'
// Mock seed data so every visual state is visible in the UI preview. Empty in a
+1 -1
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'
import { newId } from '../id'
import { newId } from '../lib/id'
/**
* A tiny transient-notification store (audit UX6/UX9). The app had no way to tell
@@ -10,13 +10,13 @@
} from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowClockwiseRegular, DismissRegular } from '@fluentui/react-icons'
import { useDownloads, type DownloadItem } from '../store/downloads'
import { queueSummaryOf } from '../store/queueStats'
import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { SPACE, ICON, META_SEP } from './ui/tokens'
import { queueSummaryOf } from '../lib/queueStats'
import { DownloadBar } from '../components/DownloadBar'
import { QueueItem } from '../components/QueueItem'
import { VirtualList } from '../components/VirtualList'
import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { EmptyState } from '../components/ui/EmptyState'
import { SPACE, ICON, META_SEP } from '../components/ui/tokens'
// Hoisted so the virtualizer gets stable function identities and doesn't churn its
// measurement/key cache on every render (PERF5).
@@ -22,21 +22,21 @@ import {
} from '@fluentui/react-icons'
import type { HistoryEntry, MediaKind } from '@shared/ipc'
import { useHistory } from '../store/history'
import { formatWhen } from '../datetime'
import { formatWhen } from '../lib/datetime'
import { useResolvedDark } from '../store/systemTheme'
import { useDownloads } from '../store/downloads'
import { thumbColors } from '../theme'
import { thumbUrl } from '../thumb'
import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
import { Select } from './Select'
import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, ICON, META_SEP } from './ui/tokens'
import { THUMB_SM } from '../thumbSizes'
import { thumbColors } from '../lib/theme'
import { thumbUrl } from '../lib/thumb'
import { MediaThumb } from '../components/MediaThumb'
import { Hint } from '../components/Hint'
import { Select } from '../components/Select'
import { VirtualList } from '../components/VirtualList'
import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { EmptyState } from '../components/ui/EmptyState'
import { useFocusStyles } from '../components/ui/focusRing'
import { useTextStyles } from '../components/ui/text'
import { SPACE, ICON, META_SEP } from '../components/ui/tokens'
import { THUMB_SM } from '../lib/thumbSizes'
const useStyles = makeStyles({
root: {
@@ -29,22 +29,22 @@ import type { MediaItem, Source, MediaKind } from '@shared/ipc'
import { useSources } from '../store/sources'
import { useSettings } from '../store/settings'
import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { relTime } from '../datetime'
import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList'
import { Hint } from './Hint'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { StatusChip } from './ui/StatusChip'
import { SegmentedControl } from './ui/SegmentedControl'
import { EmptyState } from './ui/EmptyState'
import { LinkSuggestion } from './ui/LinkSuggestion'
import { useFocusStyles } from './ui/focusRing'
import { useTextStyles } from './ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
import { THUMB_XS } from '../thumbSizes'
import { thumbUrl } from '../lib/thumb'
import { relTime } from '../lib/datetime'
import { MediaThumb } from '../components/MediaThumb'
import { VirtualList } from '../components/VirtualList'
import { Hint } from '../components/Hint'
import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { StatusChip } from '../components/ui/StatusChip'
import { SegmentedControl } from '../components/ui/SegmentedControl'
import { EmptyState } from '../components/ui/EmptyState'
import { LinkSuggestion } from '../components/ui/LinkSuggestion'
import { useFocusStyles } from '../components/ui/focusRing'
import { useTextStyles } from '../components/ui/text'
import { SPACE, RADIUS, ICON, META_SEP } from '../components/ui/tokens'
import { THUMB_XS } from '../lib/thumbSizes'
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
type ItemStatus = DownloadStatus | 'pending'
@@ -22,7 +22,7 @@ import {
KeyboardRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { SPACE, ICON } from './ui/tokens'
import { SPACE, ICON } from '../components/ui/tokens'
const useStyles = makeStyles({
root: {
@@ -12,8 +12,8 @@ import {
} from '@fluentui/react-components'
import { SearchRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { SPACE } from './ui/tokens'
import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { SPACE } from '../components/ui/tokens'
import { DownloadsCard } from './settings/DownloadsCard'
import { AppearanceCard } from './settings/AppearanceCard'
import { PostProcessingCard } from './settings/PostProcessingCard'
@@ -11,9 +11,9 @@ import {
} from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { EmptyState } from './ui/EmptyState'
import { SPACE } from './ui/tokens'
import { ScreenHeader, useScreenStyles } from '../components/ui/Screen'
import { EmptyState } from '../components/ui/EmptyState'
import { SPACE } from '../components/ui/tokens'
import { useTerminalRun, type LineKind } from './useTerminalRun'
const useStyles = makeStyles({
@@ -13,8 +13,8 @@ import {
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
import { type YtdlpUpdateChannel } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { Select } from '../Select'
import { useErrorTextStyles } from '../ui/errorText'
import { Select } from '../../components/Select'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles'
import { useAboutCard } from './useAboutCard'
@@ -12,9 +12,9 @@ import { PaintBucketRegular, AccessibilityRegular } from '@fluentui/react-icons'
import { type ThemeMode, type AccentColor } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useSystemTheme } from '../../store/systemTheme'
import { Select } from '../Select'
import { ACCENT_OPTIONS } from '../../theme'
import { useFocusStyles } from '../ui/focusRing'
import { Select } from '../../components/Select'
import { ACCENT_OPTIONS } from '../../lib/theme'
import { useFocusStyles } from '../../components/ui/focusRing'
import { useSettingsStyles } from './settingsStyles'
const THEME_MODE_OPTIONS = [
@@ -1,6 +1,6 @@
import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { DocumentArrowDownRegular, DocumentArrowUpRegular } from '@fluentui/react-icons'
import { useErrorTextStyles } from '../ui/errorText'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles'
import { useBackupCard } from './useBackupCard'
@@ -2,8 +2,8 @@ import { Field, Input, Button, Card, Subtitle2, Caption1 } from '@fluentui/react
import { CookiesRegular } from '@fluentui/react-icons'
import { COOKIE_BROWSERS, type CookieSource, type CookieBrowser } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { Select } from '../Select'
import { useErrorTextStyles } from '../ui/errorText'
import { Select } from '../../components/Select'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles'
import { useCookiesCard } from './useCookiesCard'
@@ -2,8 +2,8 @@ import { Field, Switch, Card, Subtitle2, Caption1 } from '@fluentui/react-compon
import { CodeRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useTemplates } from '../../store/templates'
import { Select } from '../Select'
import { TemplateManager } from '../TemplateManager'
import { Select } from '../../components/Select'
import { TemplateManager } from '../../components/TemplateManager'
import { useSettingsStyles } from './settingsStyles'
export function CustomCommandsCard(): React.JSX.Element {
@@ -2,8 +2,8 @@ import { useState } from 'react'
import { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-components'
import { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons'
import { useErrorLog } from '../../store/errorlog'
import { useErrorTextStyles } from '../ui/errorText'
import { EmptyState } from '../ui/EmptyState'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { EmptyState } from '../../components/ui/EmptyState'
import { useSettingsStyles } from './settingsStyles'
export function DiagnosticsCard(): React.JSX.Element {
@@ -11,9 +11,9 @@ import { FolderRegular, ArrowDownloadRegular } from '@fluentui/react-icons'
import { type MediaKind } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useDownloads } from '../../store/downloads'
import { QUALITY_OPTIONS } from '../../qualityOptions'
import { Select } from '../Select'
import { SegmentedControl } from '../ui/SegmentedControl'
import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
import { Select } from '../../components/Select'
import { SegmentedControl } from '../../components/ui/SegmentedControl'
import { useSettingsStyles } from './settingsStyles'
export function DownloadsCard(): React.JSX.Element {
@@ -1,7 +1,7 @@
import { Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { OptionsRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { DownloadOptionsForm } from '../DownloadOptionsForm'
import { DownloadOptionsForm } from '../../components/DownloadOptionsForm'
import { useSettingsStyles } from './settingsStyles'
export function PostProcessingCard(): React.JSX.Element {
@@ -17,7 +17,7 @@ import {
DismissRegular
} from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useErrorTextStyles } from '../ui/errorText'
import { useErrorTextStyles } from '../../components/ui/errorText'
import { useSettingsStyles } from './settingsStyles'
import { useSoftwareUpdateCard } from './useSoftwareUpdateCard'
@@ -1,5 +1,5 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
import { SPACE, RADIUS, ICON } from '../ui/tokens'
import { SPACE, RADIUS, ICON } from '../../components/ui/tokens'
// Shared styles for the Settings cards. Each card renders a single <Card> so it
// stays one direct child of SettingsView's root -- the search filter toggles
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { newId } from '../id'
import { newId } from '../lib/id'
export type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
export interface Line {
+1 -1
View File
@@ -12,7 +12,7 @@ import {
aria2cArgs,
DEST_MARKER,
type AccessOptions
} from '../src/main/buildArgs'
} from '../src/main/core/buildArgs'
import {
DEFAULT_DOWNLOAD_OPTIONS,
type DownloadOptions,
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { relTime, formatWhen, fmtSchedule } from '../src/renderer/src/datetime'
import { relTime, formatWhen, fmtSchedule } from '../src/renderer/src/lib/datetime'
afterEach(() => {
vi.useRealTimers()
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { newId } from '../src/renderer/src/id'
import { newId } from '../src/renderer/src/lib/id'
describe('newId', () => {
it('produces unique ids across many calls', () => {
+1 -1
View File
@@ -11,7 +11,7 @@ import {
stripTabSuffix,
stableSourceId,
type RawEntry
} from '../src/main/indexerCore'
} from '../src/main/core/indexerCore'
import type { MediaItem } from '@shared/ipc'
describe('classifySource', () => {
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { summarizeQueue, sameVideo } from '../src/renderer/src/store/queueStats'
import { summarizeQueue, sameVideo } from '../src/renderer/src/lib/queueStats'
import type { DownloadItem } from '../src/renderer/src/store/downloads'
// Minimal item factory — only the fields summarizeQueue reads.
+1 -1
View File
@@ -13,7 +13,7 @@ import { spawnSync } from 'child_process'
import { mkdtempSync, existsSync, rmSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os'
import { join, resolve } from 'path'
import { buildArgs, parseExtraArgs } from '../src/main/buildArgs'
import { buildArgs, parseExtraArgs } from '../src/main/core/buildArgs'
import { DEFAULT_DOWNLOAD_OPTIONS, type StartDownloadOptions } from '@shared/ipc'
const RUN = process.env.AEROFETCH_REAL_DOWNLOAD === '1'
+14 -14
View File
@@ -138,7 +138,7 @@ afterAll(() => {
describe('downloadAppUpdate (CL4 behavior pins)', () => {
it('refuses an untrusted initial URL without making any request', async () => {
const { wc } = fakeWc()
const r = await downloadAppUpdate('https://evil.example/x.exe', wc)
const r = await downloadAppUpdate(wc, 'https://evil.example/x.exe')
expect(r.ok).toBe(false)
expect(r.error).toMatch(/untrusted/i)
expect(madeRequests.length).toBe(0)
@@ -154,7 +154,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end')
})
const { wc, sends } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r).toEqual({ ok: true, filePath: installerPath })
expect(readFileSync(installerPath).equals(INSTALLER)).toBe(true)
expect(sends.length).toBeGreaterThan(0)
@@ -171,7 +171,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end')
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/checksum/i)
await expectGone(installerPath)
@@ -186,7 +186,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end')
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/incomplete/i)
await expectGone(installerPath)
@@ -198,7 +198,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
req.emit('redirect', 302, 'GET', 'https://evil.example/attachments/x.exe')
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/redirect.*untrusted/i)
const installerReq = madeRequests[1]!
@@ -216,7 +216,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end')
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(true)
expect(madeRequests[1]!.redirectsFollowed).toBe(1)
})
@@ -226,7 +226,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
req.emit('redirect', 302, 'GET', 'https://evil.example/x.sha256')
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/checksum/i)
expect(madeRequests.length).toBe(1) // never reached the installer request
@@ -237,7 +237,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
req.emit('response', new FakeResponse(404))
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/no checksum/i)
expect(madeRequests.length).toBe(1)
@@ -246,7 +246,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
it('refuses a malformed checksum file (no 64-hex digest)', async () => {
requestScripts.push(checksumOk('not a digest at all\n'))
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/malformed/i)
expect(madeRequests.length).toBe(1)
@@ -262,7 +262,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end')
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/canceled/i)
expect(madeRequests.length).toBe(1)
@@ -277,7 +277,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
cancelAppUpdate()
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/canceled/i)
expect(madeRequests[1]!.aborted).toBe(true)
@@ -294,7 +294,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
// …and then nothing: the stream stalls.
})
const { wc } = fakeWc()
const pending = downloadAppUpdate(ASSET, wc)
const pending = downloadAppUpdate(wc, ASSET)
await vi.advanceTimersByTimeAsync(61_000)
const r = await pending
expect(r.ok).toBe(false)
@@ -310,7 +310,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
req.emit('response', new FakeResponse(503))
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(false)
expect(r.error).toMatch(/503/)
})
@@ -327,7 +327,7 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
res.emit('end')
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(ASSET, wc)
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(true)
})
})
+1 -1
View File
@@ -7,7 +7,7 @@ import {
isTemplateLike,
isValidSource,
isValidMediaItem
} from '../src/main/validation'
} from '../src/main/core/validation'
import {
isValidSyncTime,
type HistoryEntry,
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { shouldAutoCheckYtdlp, AUTO_UPDATE_INTERVAL_MS } from '../src/main/ytdlpPolicy'
import { shouldAutoCheckYtdlp, AUTO_UPDATE_INTERVAL_MS } from '../src/main/core/ytdlpPolicy'
const NOW = 1_782_435_469_486 // arbitrary fixed "now"